Compare commits

...

1 Commits

Author SHA1 Message Date
Altay
cecadc3bbc
fix: stabilize status startup and windows test paths 2026-03-17 01:36:55 +03:00
12 changed files with 139 additions and 47 deletions

View File

@ -5,7 +5,7 @@ import { rawDataToString } from "../infra/ws.js";
import { redactSensitiveText } from "../logging/redact.js"; import { redactSensitiveText } from "../logging/redact.js";
import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js"; import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
import { resolveBrowserRateLimitMessage } from "./client-fetch.js"; import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js";
export { isLoopbackHost }; export { isLoopbackHost };

View File

@ -7,6 +7,7 @@ import {
createBrowserControlContext, createBrowserControlContext,
startBrowserControlServiceFromConfig, startBrowserControlServiceFromConfig,
} from "./control-service.js"; } from "./control-service.js";
import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js";
import { createBrowserRouteDispatcher } from "./routes/dispatcher.js"; import { createBrowserRouteDispatcher } from "./routes/dispatcher.js";
// Application-level error from the browser control service (service is reachable // Application-level error from the browser control service (service is reachable
@ -102,36 +103,10 @@ const BROWSER_TOOL_MODEL_HINT =
"Do NOT retry the browser tool — it will keep failing. " + "Do NOT retry the browser tool — it will keep failing. " +
"Use an alternative approach or inform the user that the browser is currently unavailable."; "Use an alternative approach or inform the user that the browser is currently unavailable.";
const BROWSER_SERVICE_RATE_LIMIT_MESSAGE =
"Browser service rate limit reached. " +
"Wait for the current session to complete, or retry later.";
const BROWSERBASE_RATE_LIMIT_MESSAGE =
"Browserbase rate limit reached (max concurrent sessions). " +
"Wait for the current session to complete, or upgrade your plan.";
function isRateLimitStatus(status: number): boolean { function isRateLimitStatus(status: number): boolean {
return status === 429; return status === 429;
} }
function isBrowserbaseUrl(url: string): boolean {
if (!isAbsoluteHttp(url)) {
return false;
}
try {
const host = new URL(url).hostname.toLowerCase();
return host === "browserbase.com" || host.endsWith(".browserbase.com");
} catch {
return false;
}
}
export function resolveBrowserRateLimitMessage(url: string): string {
return isBrowserbaseUrl(url)
? BROWSERBASE_RATE_LIMIT_MESSAGE
: BROWSER_SERVICE_RATE_LIMIT_MESSAGE;
}
function resolveBrowserFetchOperatorHint(url: string): string { function resolveBrowserFetchOperatorHint(url: string): string {
const isLocal = !isAbsoluteHttp(url); const isLocal = !isAbsoluteHttp(url);
return isLocal return isLocal

View File

@ -1,7 +1,20 @@
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { resolveGatewayAuth } from "../gateway/auth.js"; import { resolveGatewayAuth } from "../gateway/auth.js";
import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js";
let configModulePromise: Promise<typeof import("../config/config.js")> | undefined;
let gatewayStartupAuthModulePromise:
| Promise<typeof import("../gateway/startup-auth.js")>
| undefined;
async function loadConfigModule() {
configModulePromise ??= import("../config/config.js");
return await configModulePromise;
}
async function loadGatewayStartupAuthModule() {
gatewayStartupAuthModulePromise ??= import("../gateway/startup-auth.js");
return await gatewayStartupAuthModulePromise;
}
export type BrowserControlAuth = { export type BrowserControlAuth = {
token?: string; token?: string;
@ -67,6 +80,7 @@ export async function ensureBrowserControlAuth(params: {
} }
// Re-read latest config to avoid racing with concurrent config writers. // Re-read latest config to avoid racing with concurrent config writers.
const { loadConfig } = await loadConfigModule();
const latestCfg = loadConfig(); const latestCfg = loadConfig();
const latestAuth = resolveBrowserControlAuth(latestCfg, env); const latestAuth = resolveBrowserControlAuth(latestCfg, env);
if (latestAuth.token || latestAuth.password) { if (latestAuth.token || latestAuth.password) {
@ -82,6 +96,7 @@ export async function ensureBrowserControlAuth(params: {
return { auth: latestAuth }; return { auth: latestAuth };
} }
const { ensureGatewayStartupAuth } = await loadGatewayStartupAuthModule();
const ensured = await ensureGatewayStartupAuth({ const ensured = await ensureGatewayStartupAuth({
cfg: latestCfg, cfg: latestCfg,
env, env,

View File

@ -0,0 +1,29 @@
function isAbsoluteHttp(url: string): boolean {
return /^https?:\/\//i.test(url.trim());
}
function isBrowserbaseUrl(url: string): boolean {
if (!isAbsoluteHttp(url)) {
return false;
}
try {
const host = new URL(url).hostname.toLowerCase();
return host === "browserbase.com" || host.endsWith(".browserbase.com");
} catch {
return false;
}
}
const BROWSER_SERVICE_RATE_LIMIT_MESSAGE =
"Browser service rate limit reached. " +
"Wait for the current session to complete, or retry later.";
const BROWSERBASE_RATE_LIMIT_MESSAGE =
"Browserbase rate limit reached (max concurrent sessions). " +
"Wait for the current session to complete, or upgrade your plan.";
export function resolveBrowserRateLimitMessage(url: string): string {
return isBrowserbaseUrl(url)
? BROWSERBASE_RATE_LIMIT_MESSAGE
: BROWSER_SERVICE_RATE_LIMIT_MESSAGE;
}

View File

@ -36,7 +36,7 @@ describe("ensurePluginRegistryLoaded", () => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.loadConfig.mockReturnValue({ mocks.loadConfig.mockReturnValue({
plugins: { enabled: true }, plugins: { enabled: true },
channels: { telegram: { enabled: false } }, channels: { telegram: { botToken: "telegram-test-token" } },
}); });
mocks.loadPluginManifestRegistry.mockReturnValue({ mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [ plugins: [

View File

@ -177,8 +177,6 @@ export async function getStatusSummary(
} = {}, } = {},
): Promise<StatusSummary> { ): Promise<StatusSummary> {
const { includeSensitive = true } = options; const { includeSensitive = true } = options;
const { classifySessionKey, resolveContextTokensForModel, resolveSessionModelRef } =
await loadStatusSummaryRuntimeModule();
const cfg = options.config ?? (await loadConfigIoModule()).loadConfig(); const cfg = options.config ?? (await loadConfigIoModule()).loadConfig();
const needsChannelPlugins = hasPotentialConfiguredChannels(cfg); const needsChannelPlugins = hasPotentialConfiguredChannels(cfg);
const linkContext = needsChannelPlugins const linkContext = needsChannelPlugins
@ -196,6 +194,50 @@ export async function getStatusSummary(
everyMs: summary.everyMs, everyMs: summary.everyMs,
} satisfies HeartbeatStatus; } satisfies HeartbeatStatus;
}); });
const mainSessionKey = resolveMainSessionKey(cfg);
const queuedSystemEvents = peekSystemEvents(mainSessionKey);
const agentStorePaths = agentList.agents.map((agent) => ({
agentId: agent.id,
path: resolveStorePath(cfg.session?.store, { agentId: agent.id }),
}));
const hasAnyStoredSessions = agentStorePaths.some(({ path }) => {
const store = readSessionStoreReadOnly(path);
return Object.keys(store).some((key) => key !== "global" && key !== "unknown");
});
if (
!needsChannelPlugins &&
!hasAnyStoredSessions &&
queuedSystemEvents.length === 0 &&
Object.keys(cfg).length === 0
) {
const summary: StatusSummary = {
runtimeVersion: resolveRuntimeServiceVersion(process.env),
heartbeat: {
defaultAgentId: agentList.defaultId,
agents: heartbeatAgents,
},
channelSummary: [],
queuedSystemEvents,
sessions: {
paths: agentStorePaths.map(({ path }) => path),
count: 0,
defaults: {
model: DEFAULT_MODEL,
contextTokens: DEFAULT_CONTEXT_TOKENS,
},
recent: [],
byAgent: agentStorePaths.map(({ agentId, path }) => ({
agentId,
path,
count: 0,
recent: [],
})),
},
};
return includeSensitive ? summary : redactSensitiveStatusSummary(summary);
}
const channelSummary = needsChannelPlugins const channelSummary = needsChannelPlugins
? await loadChannelSummaryModule().then(({ buildChannelSummary }) => ? await loadChannelSummaryModule().then(({ buildChannelSummary }) =>
buildChannelSummary(cfg, { buildChannelSummary(cfg, {
@ -205,8 +247,8 @@ export async function getStatusSummary(
}), }),
) )
: []; : [];
const mainSessionKey = resolveMainSessionKey(cfg); const { classifySessionKey, resolveContextTokensForModel, resolveSessionModelRef } =
const queuedSystemEvents = peekSystemEvents(mainSessionKey); await loadStatusSummaryRuntimeModule();
const resolved = resolveConfiguredStatusModelRef({ const resolved = resolveConfiguredStatusModelRef({
cfg, cfg,
@ -295,13 +337,12 @@ export async function getStatusSummary(
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
const paths = new Set<string>(); const paths = new Set<string>();
const byAgent = agentList.agents.map((agent) => { const byAgent = agentStorePaths.map(({ agentId, path: storePath }) => {
const storePath = resolveStorePath(cfg.session?.store, { agentId: agent.id });
paths.add(storePath); paths.add(storePath);
const store = loadStore(storePath); const store = loadStore(storePath);
const sessions = buildSessionRows(store, { agentIdOverride: agent.id }); const sessions = buildSessionRows(store, { agentIdOverride: agentId });
return { return {
agentId: agent.id, agentId,
path: storePath, path: storePath,
count: sessions.length, count: sessions.length,
recent: sessions.slice(0, 10), recent: sessions.slice(0, 10),

View File

@ -1,11 +1,17 @@
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayCredentialsWithSecretInputs } from "./call.js";
import { import {
type ExplicitGatewayAuth, type ExplicitGatewayAuth,
isGatewaySecretRefUnavailableError, isGatewaySecretRefUnavailableError,
resolveGatewayProbeCredentialsFromConfig, resolveGatewayProbeCredentialsFromConfig,
} from "./credentials.js"; } from "./credentials.js";
let gatewayCallModulePromise: Promise<typeof import("./call.js")> | undefined;
async function loadGatewayCallModule() {
gatewayCallModulePromise ??= import("./call.js");
return await gatewayCallModulePromise;
}
function buildGatewayProbeCredentialPolicy(params: { function buildGatewayProbeCredentialPolicy(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
mode: "local" | "remote"; mode: "local" | "remote";
@ -40,6 +46,7 @@ export async function resolveGatewayProbeAuthWithSecretInputs(params: {
explicitAuth?: ExplicitGatewayAuth; explicitAuth?: ExplicitGatewayAuth;
}): Promise<{ token?: string; password?: string }> { }): Promise<{ token?: string; password?: string }> {
const policy = buildGatewayProbeCredentialPolicy(params); const policy = buildGatewayProbeCredentialPolicy(params);
const { resolveGatewayCredentialsWithSecretInputs } = await loadGatewayCallModule();
return await resolveGatewayCredentialsWithSecretInputs({ return await resolveGatewayCredentialsWithSecretInputs({
config: policy.config, config: policy.config,
env: policy.env, env: policy.env,

View File

@ -12,6 +12,14 @@ import {
import { loadInternalHooks } from "./loader.js"; import { loadInternalHooks } from "./loader.js";
import { loadWorkspaceHookEntries } from "./workspace.js"; import { loadWorkspaceHookEntries } from "./workspace.js";
function canonicalizePathForAssertion(filePath: string): string {
try {
return fs.realpathSync.native(filePath);
} catch {
return path.resolve(filePath);
}
}
describe("bundle plugin hooks", () => { describe("bundle plugin hooks", () => {
let fixtureRoot = ""; let fixtureRoot = "";
let caseId = 0; let caseId = 0;
@ -106,8 +114,8 @@ describe("bundle plugin hooks", () => {
expect(entries[0]?.hook.name).toBe("bundle-hook"); expect(entries[0]?.hook.name).toBe("bundle-hook");
expect(entries[0]?.hook.source).toBe("openclaw-plugin"); expect(entries[0]?.hook.source).toBe("openclaw-plugin");
expect(entries[0]?.hook.pluginId).toBe("sample-bundle"); expect(entries[0]?.hook.pluginId).toBe("sample-bundle");
expect(entries[0]?.hook.baseDir).toBe( expect(canonicalizePathForAssertion(entries[0]?.hook.baseDir ?? "")).toBe(
fs.realpathSync.native(path.join(bundleRoot, "hooks", "bundle-hook")), canonicalizePathForAssertion(path.join(bundleRoot, "hooks", "bundle-hook")),
); );
expect(entries[0]?.metadata?.events).toEqual(["command:new"]); expect(entries[0]?.metadata?.events).toEqual(["command:new"]);
}); });

View File

@ -36,7 +36,9 @@ function resolveDefaultLogDir(): string {
} }
export const DEFAULT_LOG_DIR = resolveDefaultLogDir(); export const DEFAULT_LOG_DIR = resolveDefaultLogDir();
export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "openclaw.log"); // legacy single-file path export const DEFAULT_LOG_FILE = canUseNodeFs()
? path.join(DEFAULT_LOG_DIR, "openclaw.log")
: path.posix.join(DEFAULT_LOG_DIR, "openclaw.log"); // legacy single-file path
const LOG_PREFIX = "openclaw"; const LOG_PREFIX = "openclaw";
const LOG_SUFFIX = ".log"; const LOG_SUFFIX = ".log";

View File

@ -9,6 +9,10 @@ import { clearPluginManifestRegistryCache } from "./manifest-registry.js";
const tempDirs: string[] = []; const tempDirs: string[] = [];
async function canonicalizePathForAssertion(filePath: string): Promise<string> {
return await fs.realpath(filePath).catch(() => path.resolve(filePath));
}
async function createTempDir(prefix: string): Promise<string> { async function createTempDir(prefix: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir); tempDirs.push(dir);
@ -72,11 +76,16 @@ describe("loadEnabledBundleMcpConfig", () => {
workspaceDir, workspaceDir,
cfg: config, cfg: config,
}); });
const resolvedServerPath = await fs.realpath(serverPath); const loadedServerArgs = loaded.config.mcpServers.bundleProbe?.args;
const loadedServerPath = Array.isArray(loadedServerArgs) ? loadedServerArgs[0] : undefined;
expect(loaded.diagnostics).toEqual([]); expect(loaded.diagnostics).toEqual([]);
expect(loaded.config.mcpServers.bundleProbe?.command).toBe("node"); expect(loaded.config.mcpServers.bundleProbe?.command).toBe("node");
expect(loaded.config.mcpServers.bundleProbe?.args).toEqual([resolvedServerPath]); expect(Array.isArray(loadedServerArgs)).toBe(true);
expect(typeof loadedServerPath).toBe("string");
expect(await canonicalizePathForAssertion(String(loadedServerPath))).toBe(
await canonicalizePathForAssertion(serverPath),
);
} finally { } finally {
env.restore(); env.restore();
} }

View File

@ -10,6 +10,10 @@ vi.mock("./install.js", () => ({
installPluginFromPath: (...args: unknown[]) => installPluginFromPathMock(...args), installPluginFromPath: (...args: unknown[]) => installPluginFromPathMock(...args),
})); }));
function normalizePathForAssertion(value: string): string {
return value.replaceAll("\\", "/");
}
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> { async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-test-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-test-"));
try { try {
@ -45,9 +49,8 @@ describe("marketplace plugins", () => {
const { listMarketplacePlugins } = await import("./marketplace.js"); const { listMarketplacePlugins } = await import("./marketplace.js");
const result = await listMarketplacePlugins({ marketplace: rootDir }); const result = await listMarketplacePlugins({ marketplace: rootDir });
expect(result).toEqual({ expect(result).toMatchObject({
ok: true, ok: true,
sourceLabel: expect.stringContaining(".claude-plugin/marketplace.json"),
manifest: { manifest: {
name: "Example Marketplace", name: "Example Marketplace",
version: "1.0.0", version: "1.0.0",
@ -61,6 +64,9 @@ describe("marketplace plugins", () => {
], ],
}, },
}); });
expect(result.ok && normalizePathForAssertion(result.sourceLabel)).toContain(
".claude-plugin/marketplace.json",
);
}); });
}); });

View File

@ -1,6 +1,6 @@
import { isIP } from "node:net"; import { isIP } from "node:net";
import path from "node:path"; import path from "node:path";
import { resolveSandboxConfigForAgent } from "../agents/sandbox.js"; import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
import { redactCdpUrl } from "../browser/cdp.helpers.js"; import { redactCdpUrl } from "../browser/cdp.helpers.js";
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
import { resolveBrowserControlAuth } from "../browser/control-auth.js"; import { resolveBrowserControlAuth } from "../browser/control-auth.js";