fix(status): bound deep docker audit probes (#85476)

* fix(status): bound deep docker audit probes

* chore(status): defer changelog entry to landing

* docs(changelog): note status docker probe timeout

* fix(status): surface Docker probe timeouts

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Gio Della-Libera
2026-05-23 04:08:48 -07:00
committed by GitHub
parent a7e0fa08e7
commit f7c05dcc9e
6 changed files with 198 additions and 22 deletions

View File

@@ -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.

View File

@@ -326,6 +326,7 @@ describe("status-runtime-shared", () => {
config: { gateway: {} },
sourceConfig: { gateway: { mode: "local" } },
deep: false,
deepTimeoutMs: 1234,
includeFilesystem: true,
includeChannelSecurity: true,
loadPluginSecurityCollectors: false,

View File

@@ -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<StatusSecurityAudit>;
resolveUsage?: (input: StatusUsageSummaryOptions) => Promise<StatusUsageSummary>;
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({

View File

@@ -39,6 +39,8 @@ type ExecDockerRawFn = (
opts?: { allowFailure?: boolean; input?: Buffer | string; signal?: AbortSignal },
) => Promise<import("../agents/sandbox/docker.js").ExecDockerRawResult>;
const DEFAULT_SANDBOX_BROWSER_DOCKER_PROBE_TIMEOUT_MS = 5000;
type CodeSafetySummaryCache = Map<string, Promise<unknown>>;
let skillsModulePromise: Promise<typeof import("../agents/skills.js")> | undefined;
let configModulePromise: Promise<typeof import("../config/config.js")> | undefined;
@@ -274,13 +276,63 @@ function normalizeDockerLabelValue(raw: string | undefined): string | null {
return trimmed;
}
async function listSandboxBrowserContainers(
execDockerRawFn: ExecDockerRawFn,
): Promise<string[] | null> {
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<T>(
timeoutMs: number,
run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
let timeout: NodeJS.Timeout | undefined;
let timedOut = false;
const timeoutPromise = new Promise<never>((_, 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<string[] | null> {
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<string[] | null> {
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<SecurityAuditFinding[]> {
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;

View File

@@ -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[]) => {

View File

@@ -1096,6 +1096,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
findings.push(
...(await auditNonDeep.collectSandboxBrowserHashLabelFindings({
execDockerRawFn: context.execDockerRawFn,
timeoutMs: context.deepTimeoutMs,
})),
);
findings.push(...(await auditNonDeep.collectPluginsTrustFindings({ cfg, stateDir })));