diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 3bc02362b55..5a9b87fc1b3 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -5,7 +5,7 @@ import { rawDataToString } from "../infra/ws.js"; import { redactSensitiveText } from "../logging/redact.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 { resolveBrowserRateLimitMessage } from "./client-fetch.js"; +import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js"; export { isLoopbackHost }; diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index e321c5a1e62..01cd9979018 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -7,6 +7,7 @@ import { createBrowserControlContext, startBrowserControlServiceFromConfig, } from "./control-service.js"; +import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js"; import { createBrowserRouteDispatcher } from "./routes/dispatcher.js"; // 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. " + "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 { 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 { const isLocal = !isAbsoluteHttp(url); return isLocal diff --git a/src/browser/control-auth.ts b/src/browser/control-auth.ts index be7c66ab498..92cb37aa13f 100644 --- a/src/browser/control-auth.ts +++ b/src/browser/control-auth.ts @@ -1,7 +1,20 @@ import type { OpenClawConfig } from "../config/config.js"; -import { loadConfig } from "../config/config.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; -import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js"; + +let configModulePromise: Promise | undefined; +let gatewayStartupAuthModulePromise: + | Promise + | 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 = { token?: string; @@ -67,6 +80,7 @@ export async function ensureBrowserControlAuth(params: { } // Re-read latest config to avoid racing with concurrent config writers. + const { loadConfig } = await loadConfigModule(); const latestCfg = loadConfig(); const latestAuth = resolveBrowserControlAuth(latestCfg, env); if (latestAuth.token || latestAuth.password) { @@ -82,6 +96,7 @@ export async function ensureBrowserControlAuth(params: { return { auth: latestAuth }; } + const { ensureGatewayStartupAuth } = await loadGatewayStartupAuthModule(); const ensured = await ensureGatewayStartupAuth({ cfg: latestCfg, env, diff --git a/src/browser/rate-limit-message.ts b/src/browser/rate-limit-message.ts new file mode 100644 index 00000000000..fe4c81f0d21 --- /dev/null +++ b/src/browser/rate-limit-message.ts @@ -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; +} diff --git a/src/cli/plugin-registry.test.ts b/src/cli/plugin-registry.test.ts index 336c720dfdb..f8b150d48be 100644 --- a/src/cli/plugin-registry.test.ts +++ b/src/cli/plugin-registry.test.ts @@ -36,7 +36,7 @@ describe("ensurePluginRegistryLoaded", () => { vi.clearAllMocks(); mocks.loadConfig.mockReturnValue({ plugins: { enabled: true }, - channels: { telegram: { enabled: false } }, + channels: { telegram: { botToken: "telegram-test-token" } }, }); mocks.loadPluginManifestRegistry.mockReturnValue({ plugins: [ diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index c5c3f174547..a91ae472552 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -177,8 +177,6 @@ export async function getStatusSummary( } = {}, ): Promise { const { includeSensitive = true } = options; - const { classifySessionKey, resolveContextTokensForModel, resolveSessionModelRef } = - await loadStatusSummaryRuntimeModule(); const cfg = options.config ?? (await loadConfigIoModule()).loadConfig(); const needsChannelPlugins = hasPotentialConfiguredChannels(cfg); const linkContext = needsChannelPlugins @@ -196,6 +194,50 @@ export async function getStatusSummary( everyMs: summary.everyMs, } 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 ? await loadChannelSummaryModule().then(({ buildChannelSummary }) => buildChannelSummary(cfg, { @@ -205,8 +247,8 @@ export async function getStatusSummary( }), ) : []; - const mainSessionKey = resolveMainSessionKey(cfg); - const queuedSystemEvents = peekSystemEvents(mainSessionKey); + const { classifySessionKey, resolveContextTokensForModel, resolveSessionModelRef } = + await loadStatusSummaryRuntimeModule(); const resolved = resolveConfiguredStatusModelRef({ cfg, @@ -295,13 +337,12 @@ export async function getStatusSummary( .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); const paths = new Set(); - const byAgent = agentList.agents.map((agent) => { - const storePath = resolveStorePath(cfg.session?.store, { agentId: agent.id }); + const byAgent = agentStorePaths.map(({ agentId, path: storePath }) => { paths.add(storePath); const store = loadStore(storePath); - const sessions = buildSessionRows(store, { agentIdOverride: agent.id }); + const sessions = buildSessionRows(store, { agentIdOverride: agentId }); return { - agentId: agent.id, + agentId, path: storePath, count: sessions.length, recent: sessions.slice(0, 10), diff --git a/src/gateway/probe-auth.ts b/src/gateway/probe-auth.ts index 2c624acaa00..8debdde6610 100644 --- a/src/gateway/probe-auth.ts +++ b/src/gateway/probe-auth.ts @@ -1,11 +1,17 @@ import type { OpenClawConfig } from "../config/config.js"; -import { resolveGatewayCredentialsWithSecretInputs } from "./call.js"; import { type ExplicitGatewayAuth, isGatewaySecretRefUnavailableError, resolveGatewayProbeCredentialsFromConfig, } from "./credentials.js"; +let gatewayCallModulePromise: Promise | undefined; + +async function loadGatewayCallModule() { + gatewayCallModulePromise ??= import("./call.js"); + return await gatewayCallModulePromise; +} + function buildGatewayProbeCredentialPolicy(params: { cfg: OpenClawConfig; mode: "local" | "remote"; @@ -40,6 +46,7 @@ export async function resolveGatewayProbeAuthWithSecretInputs(params: { explicitAuth?: ExplicitGatewayAuth; }): Promise<{ token?: string; password?: string }> { const policy = buildGatewayProbeCredentialPolicy(params); + const { resolveGatewayCredentialsWithSecretInputs } = await loadGatewayCallModule(); return await resolveGatewayCredentialsWithSecretInputs({ config: policy.config, env: policy.env, diff --git a/src/hooks/plugin-hooks.test.ts b/src/hooks/plugin-hooks.test.ts index 333c3a3cf39..2381d6b1321 100644 --- a/src/hooks/plugin-hooks.test.ts +++ b/src/hooks/plugin-hooks.test.ts @@ -12,6 +12,14 @@ import { import { loadInternalHooks } from "./loader.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", () => { let fixtureRoot = ""; let caseId = 0; @@ -106,8 +114,8 @@ describe("bundle plugin hooks", () => { expect(entries[0]?.hook.name).toBe("bundle-hook"); expect(entries[0]?.hook.source).toBe("openclaw-plugin"); expect(entries[0]?.hook.pluginId).toBe("sample-bundle"); - expect(entries[0]?.hook.baseDir).toBe( - fs.realpathSync.native(path.join(bundleRoot, "hooks", "bundle-hook")), + expect(canonicalizePathForAssertion(entries[0]?.hook.baseDir ?? "")).toBe( + canonicalizePathForAssertion(path.join(bundleRoot, "hooks", "bundle-hook")), ); expect(entries[0]?.metadata?.events).toEqual(["command:new"]); }); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index d73009fc696..62f8c2f2118 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -36,7 +36,9 @@ function resolveDefaultLogDir(): string { } 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_SUFFIX = ".log"; diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index ef109f4abfb..8e9028c9b92 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -9,6 +9,10 @@ import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; const tempDirs: string[] = []; +async function canonicalizePathForAssertion(filePath: string): Promise { + return await fs.realpath(filePath).catch(() => path.resolve(filePath)); +} + async function createTempDir(prefix: string): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); tempDirs.push(dir); @@ -72,11 +76,16 @@ describe("loadEnabledBundleMcpConfig", () => { workspaceDir, 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.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 { env.restore(); } diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index 14d3bda0323..65b1b5761a6 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -10,6 +10,10 @@ vi.mock("./install.js", () => ({ installPluginFromPath: (...args: unknown[]) => installPluginFromPathMock(...args), })); +function normalizePathForAssertion(value: string): string { + return value.replaceAll("\\", "/"); +} + async function withTempDir(fn: (dir: string) => Promise): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-test-")); try { @@ -45,9 +49,8 @@ describe("marketplace plugins", () => { const { listMarketplacePlugins } = await import("./marketplace.js"); const result = await listMarketplacePlugins({ marketplace: rootDir }); - expect(result).toEqual({ + expect(result).toMatchObject({ ok: true, - sourceLabel: expect.stringContaining(".claude-plugin/marketplace.json"), manifest: { name: "Example Marketplace", version: "1.0.0", @@ -61,6 +64,9 @@ describe("marketplace plugins", () => { ], }, }); + expect(result.ok && normalizePathForAssertion(result.sourceLabel)).toContain( + ".claude-plugin/marketplace.json", + ); }); }); diff --git a/src/security/audit.ts b/src/security/audit.ts index ba809a1714c..43626fcab5f 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1,6 +1,6 @@ import { isIP } from "node:net"; 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 { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; import { resolveBrowserControlAuth } from "../browser/control-auth.js";