diff --git a/CHANGELOG.md b/CHANGELOG.md index 6690a5dedd1..d859b959fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai ### Fixes - CLI/tasks: reject partially numeric `openclaw tasks audit --limit` values so audit limits must be real positive integers instead of accepting strings like `5abc`. (#84901) Thanks @jbetala7. +- Status/diagnostics: bound deep Docker audit probes so `openclaw status --deep` reports slow container checks instead of hanging behind unbounded inspection. (#85476) Thanks @giodl73-repo. - Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000. - Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79. - Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands. diff --git a/src/commands/status-runtime-shared.test.ts b/src/commands/status-runtime-shared.test.ts index 07a142dcccd..46bf54c1f39 100644 --- a/src/commands/status-runtime-shared.test.ts +++ b/src/commands/status-runtime-shared.test.ts @@ -326,6 +326,7 @@ describe("status-runtime-shared", () => { config: { gateway: {} }, sourceConfig: { gateway: { mode: "local" } }, deep: false, + deepTimeoutMs: 1234, includeFilesystem: true, includeChannelSecurity: true, loadPluginSecurityCollectors: false, diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts index b9eb072b583..c40ebe17526 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -27,6 +27,7 @@ function loadGatewayCallModule() { export async function resolveStatusSecurityAudit(params: { config: OpenClawConfig; sourceConfig: OpenClawConfig; + timeoutMs?: number; }) { const { runSecurityAudit } = await loadSecurityAuditModule(); const readOnlyPlugins = resolveReadOnlyChannelPluginsForConfig(params.config, { @@ -37,6 +38,7 @@ export async function resolveStatusSecurityAudit(params: { config: params.config, sourceConfig: params.sourceConfig, deep: false, + ...(params.timeoutMs !== undefined ? { deepTimeoutMs: params.timeoutMs } : {}), includeFilesystem: true, includeChannelSecurity: true, loadPluginSecurityCollectors: false, @@ -221,6 +223,7 @@ export async function resolveStatusRuntimeSnapshot(params: { resolveSecurityAudit?: (input: { config: OpenClawConfig; sourceConfig: OpenClawConfig; + timeoutMs?: number; }) => Promise; resolveUsage?: (input: StatusUsageSummaryOptions) => Promise; resolveHealth?: (input: { @@ -232,6 +235,7 @@ export async function resolveStatusRuntimeSnapshot(params: { ? await (params.resolveSecurityAudit ?? resolveStatusSecurityAudit)({ config: params.config, sourceConfig: params.sourceConfig, + timeoutMs: params.timeoutMs, }) : undefined; const runtimeDetails = await resolveStatusRuntimeDetails({ diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index b47ee845669..f522be4baf2 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -39,6 +39,8 @@ type ExecDockerRawFn = ( opts?: { allowFailure?: boolean; input?: Buffer | string; signal?: AbortSignal }, ) => Promise; +const DEFAULT_SANDBOX_BROWSER_DOCKER_PROBE_TIMEOUT_MS = 5000; + type CodeSafetySummaryCache = Map>; let skillsModulePromise: Promise | undefined; let configModulePromise: Promise | undefined; @@ -274,13 +276,63 @@ function normalizeDockerLabelValue(raw: string | undefined): string | null { return trimmed; } -async function listSandboxBrowserContainers( - execDockerRawFn: ExecDockerRawFn, -): Promise { +class DockerProbeTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Docker probe timed out after ${timeoutMs}ms`); + this.name = "DockerProbeTimeoutError"; + } +} + +function normalizeDockerProbeTimeoutMs(timeoutMs: number | undefined): number { + if (Number.isFinite(timeoutMs) && timeoutMs !== undefined) { + return Math.max(250, Math.floor(timeoutMs)); + } + return DEFAULT_SANDBOX_BROWSER_DOCKER_PROBE_TIMEOUT_MS; +} + +async function withDockerProbeTimeout( + timeoutMs: number, + run: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController(); + let timeout: NodeJS.Timeout | undefined; + let timedOut = false; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + reject(new DockerProbeTimeoutError(timeoutMs)); + }, timeoutMs); + }); try { - const result = await execDockerRawFn( - ["ps", "-a", "--filter", "label=openclaw.sandboxBrowser=1", "--format", "{{.Names}}"], - { allowFailure: true }, + return await Promise.race([run(controller.signal), timeoutPromise]); + } catch (err) { + if (timedOut || controller.signal.aborted) { + throw new DockerProbeTimeoutError(timeoutMs); + } + throw err; + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +function isDockerProbeTimeoutError(error: unknown): boolean { + return error instanceof DockerProbeTimeoutError; +} + +async function listSandboxBrowserContainers(params: { + execDockerRawFn: ExecDockerRawFn; + timeoutMs: number; + onTimeout?: () => void; +}): Promise { + try { + const result = await withDockerProbeTimeout(params.timeoutMs, (signal) => + params.execDockerRawFn( + ["ps", "-a", "--filter", "label=openclaw.sandboxBrowser=1", "--format", "{{.Names}}"], + { allowFailure: true, signal }, + ), ); if (result.code !== 0) { return null; @@ -290,7 +342,10 @@ async function listSandboxBrowserContainers( .split(/\r?\n/) .map((entry) => entry.trim()) .filter(Boolean); - } catch { + } catch (err) { + if (isDockerProbeTimeoutError(err)) { + params.onTimeout?.(); + } return null; } } @@ -298,16 +353,20 @@ async function listSandboxBrowserContainers( async function readSandboxBrowserHashLabels(params: { containerName: string; execDockerRawFn: ExecDockerRawFn; + timeoutMs: number; + onTimeout?: () => void; }): Promise<{ configHash: string | null; epoch: string | null } | null> { try { - const result = await params.execDockerRawFn( - [ - "inspect", - "-f", - '{{ index .Config.Labels "openclaw.configHash" }}\t{{ index .Config.Labels "openclaw.browserConfigEpoch" }}', - params.containerName, - ], - { allowFailure: true }, + const result = await withDockerProbeTimeout(params.timeoutMs, (signal) => + params.execDockerRawFn( + [ + "inspect", + "-f", + '{{ index .Config.Labels "openclaw.configHash" }}\t{{ index .Config.Labels "openclaw.browserConfigEpoch" }}', + params.containerName, + ], + { allowFailure: true, signal }, + ), ); if (result.code !== 0) { return null; @@ -317,7 +376,10 @@ async function readSandboxBrowserHashLabels(params: { configHash: normalizeDockerLabelValue(hashRaw), epoch: normalizeDockerLabelValue(epochRaw), }; - } catch { + } catch (err) { + if (isDockerProbeTimeoutError(err)) { + params.onTimeout?.(); + } return null; } } @@ -349,11 +411,16 @@ function isLoopbackPublishHost(host: string): boolean { async function readSandboxBrowserPortMappings(params: { containerName: string; execDockerRawFn: ExecDockerRawFn; + timeoutMs: number; + onTimeout?: () => void; }): Promise { try { - const result = await params.execDockerRawFn(["port", params.containerName], { - allowFailure: true, - }); + const result = await withDockerProbeTimeout(params.timeoutMs, (signal) => + params.execDockerRawFn(["port", params.containerName], { + allowFailure: true, + signal, + }), + ); if (result.code !== 0) { return null; } @@ -362,21 +429,37 @@ async function readSandboxBrowserPortMappings(params: { .split(/\r?\n/) .map((entry) => entry.trim()) .filter(Boolean); - } catch { + } catch (err) { + if (isDockerProbeTimeoutError(err)) { + params.onTimeout?.(); + } return null; } } export async function collectSandboxBrowserHashLabelFindings(params?: { execDockerRawFn?: ExecDockerRawFn; + timeoutMs?: number; }): Promise { const findings: SecurityAuditFinding[] = []; + const timeoutMs = normalizeDockerProbeTimeoutMs(params?.timeoutMs); + let timedOut = false; + const markTimedOut = () => { + timedOut = true; + }; const [execFn, browserHashEpoch] = await Promise.all([ params?.execDockerRawFn ? Promise.resolve(params.execDockerRawFn) : loadExecDockerRaw(), loadSandboxBrowserSecurityHashEpoch(), ]); - const containers = await listSandboxBrowserContainers(execFn); + const containers = await listSandboxBrowserContainers({ + execDockerRawFn: execFn, + timeoutMs, + onTimeout: markTimedOut, + }); if (!containers || containers.length === 0) { + if (timedOut) { + findings.push(buildSandboxBrowserDockerProbeTimeoutFinding(timeoutMs)); + } return findings; } @@ -385,7 +468,15 @@ export async function collectSandboxBrowserHashLabelFindings(params?: { const nonLoopbackPublished: string[] = []; for (const containerName of containers) { - const labels = await readSandboxBrowserHashLabels({ containerName, execDockerRawFn: execFn }); + const labels = await readSandboxBrowserHashLabels({ + containerName, + execDockerRawFn: execFn, + timeoutMs, + onTimeout: markTimedOut, + }); + if (timedOut) { + break; + } if (!labels) { continue; } @@ -398,7 +489,12 @@ export async function collectSandboxBrowserHashLabelFindings(params?: { const portMappings = await readSandboxBrowserPortMappings({ containerName, execDockerRawFn: execFn, + timeoutMs, + onTimeout: markTimedOut, }); + if (timedOut) { + break; + } if (!portMappings?.length) { continue; } @@ -449,9 +545,26 @@ export async function collectSandboxBrowserHashLabelFindings(params?: { }); } + if (timedOut) { + findings.push(buildSandboxBrowserDockerProbeTimeoutFinding(timeoutMs)); + } + return findings; } +function buildSandboxBrowserDockerProbeTimeoutFinding(timeoutMs: number): SecurityAuditFinding { + return { + checkId: "sandbox.browser_container.docker_probe_timeout", + severity: "warn", + title: "Sandbox browser Docker audit probe timed out", + detail: + `Docker did not answer within ${timeoutMs}ms while checking sandbox browser containers. ` + + "OpenClaw skipped any remaining sandbox browser container drift checks for this status run.", + remediation: + "Retry after Docker is responsive, or recreate sandbox browser containers if drift is suspected.", + }; +} + export async function collectIncludeFilePermFindings(params: { configSnapshot: ConfigFileSnapshot; env?: NodeJS.ProcessEnv; diff --git a/src/security/audit-sandbox-browser.test.ts b/src/security/audit-sandbox-browser.test.ts index 27f16f8cb57..a35f69ea429 100644 --- a/src/security/audit-sandbox-browser.test.ts +++ b/src/security/audit-sandbox-browser.test.ts @@ -76,6 +76,62 @@ describe("security audit sandbox browser findings", () => { expect(hasFinding("sandbox.browser_container.hash_epoch_stale", "warn", findings)).toBe(false); }); + it("bounds sandbox browser Docker probes that do not return", async () => { + let probeSignal: AbortSignal | undefined; + const startedAt = Date.now(); + + const findings = await collectSandboxBrowserHashLabelFindings({ + timeoutMs: 1, + execDockerRawFn: async (_args, opts) => { + probeSignal = opts?.signal; + return await new Promise((_, reject) => + opts?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }), + ); + }, + }); + + expect(Date.now() - startedAt).toBeLessThan(1000); + expect(probeSignal?.aborted).toBe(true); + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "sandbox.browser_container.docker_probe_timeout", + severity: "warn", + }), + ]); + }); + + it("stops probing remaining sandbox browser containers after a Docker timeout", async () => { + const calls: string[] = []; + + const findings = await collectSandboxBrowserHashLabelFindings({ + timeoutMs: 1, + execDockerRawFn: async (args, opts) => { + calls.push(`${args[0] ?? ""}:${args.at(-1) ?? ""}`); + if (args[0] === "ps") { + return { + stdout: Buffer.from("openclaw-sbx-browser-hung\nopenclaw-sbx-browser-next\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + return await new Promise((_, reject) => + opts?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }), + ); + }, + }); + + expect(calls).toEqual(["ps:{{.Names}}", "inspect:openclaw-sbx-browser-hung"]); + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "sandbox.browser_container.docker_probe_timeout", + }), + ]); + }); + it("flags sandbox browser containers with non-loopback published ports", async () => { const findings = await collectSandboxBrowserHashLabelFindings({ execDockerRawFn: async (args: string[]) => { diff --git a/src/security/audit.ts b/src/security/audit.ts index c9d4514b191..3d9abaaa35f 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1096,6 +1096,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise