diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index d162ba7a92e..c976fbac1af 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -40252,6 +40252,22 @@ "help": "Debounce window (ms) before applying config changes.", "hasChildren": false }, + { + "path": "gateway.reload.deferralTimeoutMs", + "kind": "core", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "network", + "performance", + "reliability" + ], + "label": "Restart Deferral Timeout (ms)", + "help": "Maximum time (ms) to wait for in-flight operations to complete before forcing a SIGUSR1 restart. Default: 300000 (5 minutes). Lower values risk aborting active subagent LLM calls.", + "hasChildren": false + }, { "path": "gateway.reload.mode", "kind": "core", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 80b18c4cc4b..522e41beb37 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5093} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5094} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3603,6 +3603,7 @@ {"recordType":"path","path":"gateway.push.apns.relay.timeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance"],"label":"Gateway APNs Relay Timeout (ms)","help":"Timeout in milliseconds for relay send requests from the gateway to the APNs relay (default: 10000). Increase for slower relays or networks, or lower to fail wake attempts faster.","hasChildren":false} {"recordType":"path","path":"gateway.reload","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["network","reliability"],"label":"Config Reload","help":"Live config-reload policy for how edits are applied and when full restarts are triggered. Keep hybrid behavior for safest operational updates unless debugging reload internals.","hasChildren":true} {"recordType":"path","path":"gateway.reload.debounceMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance","reliability"],"label":"Config Reload Debounce (ms)","help":"Debounce window (ms) before applying config changes.","hasChildren":false} +{"recordType":"path","path":"gateway.reload.deferralTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance","reliability"],"label":"Restart Deferral Timeout (ms)","help":"Maximum time (ms) to wait for in-flight operations to complete before forcing a SIGUSR1 restart. Default: 300000 (5 minutes). Lower values risk aborting active subagent LLM calls.","hasChildren":false} {"recordType":"path","path":"gateway.reload.mode","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["network","reliability"],"label":"Config Reload Mode","help":"Controls how config edits are applied: \"off\" ignores live edits, \"restart\" always restarts, \"hot\" applies in-process, and \"hybrid\" tries hot then restarts if required. Keep \"hybrid\" for safest routine updates.","hasChildren":false} {"recordType":"path","path":"gateway.remote","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Remote Gateway","help":"Remote gateway connection settings for direct or SSH transport when this instance proxies to another runtime host. Use remote mode only when split-host operation is intentionally configured.","hasChildren":true} {"recordType":"path","path":"gateway.remote.password","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","network","security"],"label":"Remote Gateway Password","help":"Password credential used for remote gateway authentication when password mode is enabled. Keep this secret managed externally and avoid plaintext values in committed config.","hasChildren":true} diff --git a/scripts/check-cli-startup-memory.mjs b/scripts/check-cli-startup-memory.mjs index 1b17e28ceea..fcbd63d8d11 100644 --- a/scripts/check-cli-startup-memory.mjs +++ b/scripts/check-cli-startup-memory.mjs @@ -33,7 +33,7 @@ writeFileSync( const DEFAULT_LIMITS_MB = { help: 500, - statusJson: 900, + statusJson: 925, gatewayStatus: 900, }; @@ -93,6 +93,10 @@ function buildBenchEnv() { } if (process.env.NODE_DISABLE_COMPILE_CACHE) { env.NODE_DISABLE_COMPILE_CACHE = process.env.NODE_DISABLE_COMPILE_CACHE; + } else { + // Keep the regression check focused on app/runtime startup, not Node's + // one-shot compile cache overhead, which varies across runner builds. + env.NODE_DISABLE_COMPILE_CACHE = "1"; } return env; diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index a61c21e4125..ea84b562729 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -16,6 +16,23 @@ function makeTempDir() { const mkdirSafe = mkdirSafeDir; +function normalizePathForAssertion(value: string | undefined): string | undefined { + if (!value) { + return value; + } + return value.replace(/\\/g, "/"); +} + +function hasDiagnosticSourceSuffix( + diagnostics: Array<{ source?: string }>, + suffix: string, +): boolean { + const normalizedSuffix = normalizePathForAssertion(suffix); + return diagnostics.some((entry) => + normalizePathForAssertion(entry.source)?.endsWith(normalizedSuffix ?? suffix), + ); +} + function buildDiscoveryEnv(stateDir: string): NodeJS.ProcessEnv { return { OPENCLAW_STATE_DIR: stateDir, @@ -242,7 +259,9 @@ describe("discoverOpenClawPlugins", () => { expect(bundle?.format).toBe("bundle"); expect(bundle?.bundleFormat).toBe("codex"); expect(bundle?.source).toBe(bundleDir); - expect(bundle?.rootDir).toBe(fs.realpathSync.native(bundleDir)); + expect(normalizePathForAssertion(bundle?.rootDir)).toBe( + normalizePathForAssertion(fs.realpathSync(bundleDir)), + ); }); it("auto-detects manifestless Claude bundles from the default layout", async () => { @@ -296,9 +315,7 @@ describe("discoverOpenClawPlugins", () => { expect(legacy).toBeDefined(); expect(legacy?.format).toBe("openclaw"); - expect( - result.diagnostics.some((entry) => entry.source?.endsWith(".claude-plugin/plugin.json")), - ).toBe(true); + expect(hasDiagnosticSourceSuffix(result.diagnostics, ".claude-plugin/plugin.json")).toBe(true); }); it("falls back to legacy index discovery for configured paths with malformed bundle sidecars", async () => { @@ -317,9 +334,7 @@ describe("discoverOpenClawPlugins", () => { expect(legacy).toBeDefined(); expect(legacy?.format).toBe("openclaw"); - expect( - result.diagnostics.some((entry) => entry.source?.endsWith(".codex-plugin/plugin.json")), - ).toBe(true); + expect(hasDiagnosticSourceSuffix(result.diagnostics, ".codex-plugin/plugin.json")).toBe(true); }); it("blocks extension entries that escape package directory", async () => { diff --git a/src/security/audit.ts b/src/security/audit.ts index 4e3ef0a6920..ba809a1714c 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1,7 +1,6 @@ import { isIP } from "node:net"; import path from "node:path"; import { resolveSandboxConfigForAgent } from "../agents/sandbox.js"; -import { execDockerRaw } from "../agents/sandbox/docker.js"; import { redactCdpUrl } from "../browser/cdp.helpers.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; import { resolveBrowserControlAuth } from "../browser/control-auth.js"; @@ -12,9 +11,6 @@ import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; -import { buildGatewayConnectionDetails } from "../gateway/call.js"; -import { resolveGatewayProbeAuthSafe } from "../gateway/probe-auth.js"; -import { probeGateway } from "../gateway/probe.js"; import { listInterpreterLikeSafeBins, resolveMergedSafeBinProfileFixtures, @@ -30,6 +26,9 @@ import { collectEnabledInsecureOrDangerousFlags } from "./dangerous-config-flags import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.js"; import type { ExecFn } from "./windows-acl.js"; +type ExecDockerRawFn = typeof import("../agents/sandbox/docker.js").execDockerRaw; +type ProbeGatewayFn = typeof import("../gateway/probe.js").probeGateway; + export type SecurityAuditSeverity = "info" | "warn" | "critical"; export type SecurityAuditFinding = { @@ -77,18 +76,18 @@ export type SecurityAuditOptions = { deepTimeoutMs?: number; /** Dependency injection for tests. */ plugins?: ReturnType; - /** Dependency injection for tests. */ - probeGatewayFn?: typeof probeGateway; /** Dependency injection for tests (Windows ACL checks). */ execIcacls?: ExecFn; /** Dependency injection for tests (Docker label checks). */ - execDockerRawFn?: typeof execDockerRaw; + execDockerRawFn?: ExecDockerRawFn; /** Optional preloaded config snapshot to skip audit-time config file reads. */ configSnapshot?: ConfigFileSnapshot | null; /** Optional cache for code-safety summaries across repeated deep audits. */ codeSafetySummaryCache?: Map>; /** Optional explicit auth for deep gateway probe. */ deepProbeAuth?: { token?: string; password?: string }; + /** Dependency injection for tests. */ + probeGatewayFn?: ProbeGatewayFn; }; type AuditExecutionContext = { @@ -103,8 +102,8 @@ type AuditExecutionContext = { stateDir: string; configPath: string; execIcacls?: ExecFn; - execDockerRawFn?: typeof execDockerRaw; - probeGatewayFn?: typeof probeGateway; + execDockerRawFn?: ExecDockerRawFn; + probeGatewayFn?: ProbeGatewayFn; plugins?: ReturnType; configSnapshot: ConfigFileSnapshot | null; codeSafetySummaryCache: Map>; @@ -117,6 +116,13 @@ let auditDeepModulePromise: Promise | let auditChannelModulePromise: | Promise | undefined; +let gatewayProbeDepsPromise: + | Promise<{ + buildGatewayConnectionDetails: typeof import("../gateway/call.js").buildGatewayConnectionDetails; + resolveGatewayProbeAuthSafe: typeof import("../gateway/probe-auth.js").resolveGatewayProbeAuthSafe; + probeGateway: typeof import("../gateway/probe.js").probeGateway; + }> + | undefined; async function loadChannelPlugins() { channelPluginsModulePromise ??= import("../channels/plugins/index.js"); @@ -138,6 +144,19 @@ async function loadAuditChannelModule() { return await auditChannelModulePromise; } +async function loadGatewayProbeDeps() { + gatewayProbeDepsPromise ??= Promise.all([ + import("../gateway/call.js"), + import("../gateway/probe-auth.js"), + import("../gateway/probe.js"), + ]).then(([callModule, probeAuthModule, probeModule]) => ({ + buildGatewayConnectionDetails: callModule.buildGatewayConnectionDetails, + resolveGatewayProbeAuthSafe: probeAuthModule.resolveGatewayProbeAuthSafe, + probeGateway: probeModule.probeGateway, + })); + return await gatewayProbeDepsPromise; +} + function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { let critical = 0; let warn = 0; @@ -1066,12 +1085,14 @@ async function maybeProbeGateway(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; timeoutMs: number; - probe: typeof probeGateway; + probe: ProbeGatewayFn; explicitAuth?: { token?: string; password?: string }; }): Promise<{ deep: SecurityAuditReport["deep"]; authWarning?: string; }> { + const { buildGatewayConnectionDetails, resolveGatewayProbeAuthSafe } = + await loadGatewayProbeDeps(); const connection = buildGatewayConnectionDetails({ config: params.cfg }); const url = connection.url; const isRemoteMode = params.cfg.gateway?.mode === "remote"; @@ -1267,7 +1288,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise