From 7abfff756d6c68d17e21d1657bbacbaec86de232 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:44:15 -0500 Subject: [PATCH 1/9] Exec: harden host env override handling across gateway and node (#51207) * Exec: harden host env override enforcement and fail closed * Node host: enforce env override diagnostics before shell filtering * Env overrides: align Windows key handling and mac node rejection --- CHANGELOG.md | 1 + .../Sources/OpenClaw/HostEnvSanitizer.swift | 69 ++++++++- .../HostEnvSecurityPolicy.generated.swift | 18 ++- .../OpenClaw/NodeMode/MacNodeRuntime.swift | 17 +++ .../HostEnvSanitizerTests.swift | 20 +++ .../MacNodeRuntimeTests.swift | 26 ++++ src/agents/bash-tools.exec.path.test.ts | 16 ++ src/agents/bash-tools.exec.ts | 69 ++++++--- src/infra/host-env-security-policy.json | 18 ++- src/infra/host-env-security.test.ts | 61 +++++++- src/infra/host-env-security.ts | 141 +++++++++++++++--- src/node-host/invoke-system-run.test.ts | 61 ++++++++ src/node-host/invoke-system-run.ts | 33 +++- src/node-host/invoke.sanitize-env.test.ts | 7 + 14 files changed, 510 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15fe8b08613..4f533794769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -153,6 +153,7 @@ Docs: https://docs.openclaw.ai - Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant. - Gateway: harden OpenResponses file-context escaping (#50782) Thanks @YLChen-007 and @joshavant. - LINE: harden Express webhook parsing to verified raw body (#51202) Thanks @gladiator9797 and @joshavant. +- Exec: harden host env override handling across gateway and node (#51207) Thanks @gladiator9797 and @joshavant. - xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek ### Fixes diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift index d5d27a212f5..a3d92efa3f1 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift @@ -1,5 +1,10 @@ import Foundation +struct HostEnvOverrideDiagnostics: Equatable { + var blockedKeys: [String] + var invalidKeys: [String] +} + enum HostEnvSanitizer { /// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs. /// Parity is validated by src/infra/host-env-security.policy-parity.test.ts. @@ -41,6 +46,67 @@ enum HostEnvSanitizer { return filtered.isEmpty ? nil : filtered } + private static func isPortableHead(_ scalar: UnicodeScalar) -> Bool { + let value = scalar.value + return value == 95 || (65...90).contains(value) || (97...122).contains(value) + } + + private static func isPortableTail(_ scalar: UnicodeScalar) -> Bool { + let value = scalar.value + return self.isPortableHead(scalar) || (48...57).contains(value) + } + + private static func normalizeOverrideKey(_ rawKey: String) -> String? { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return nil } + guard let first = key.unicodeScalars.first, self.isPortableHead(first) else { + return nil + } + for scalar in key.unicodeScalars.dropFirst() { + if self.isPortableTail(scalar) || scalar == "(" || scalar == ")" { + continue + } + return nil + } + return key + } + + private static func sortedUnique(_ values: [String]) -> [String] { + Array(Set(values)).sorted() + } + + static func inspectOverrides( + overrides: [String: String]?, + blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics + { + guard let overrides else { + return HostEnvOverrideDiagnostics(blockedKeys: [], invalidKeys: []) + } + + var blocked: [String] = [] + var invalid: [String] = [] + for (rawKey, _) in overrides { + let candidate = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard let normalized = self.normalizeOverrideKey(rawKey) else { + invalid.append(candidate.isEmpty ? rawKey : candidate) + continue + } + let upper = normalized.uppercased() + if blockPathOverrides, upper == "PATH" { + blocked.append(upper) + continue + } + if self.isBlockedOverride(upper) || self.isBlocked(upper) { + blocked.append(upper) + continue + } + } + + return HostEnvOverrideDiagnostics( + blockedKeys: self.sortedUnique(blocked), + invalidKeys: self.sortedUnique(invalid)) + } + static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] { var merged: [String: String] = [:] for (rawKey, value) in ProcessInfo.processInfo.environment { @@ -57,8 +123,7 @@ enum HostEnvSanitizer { guard let effectiveOverrides else { return merged } for (rawKey, value) in effectiveOverrides { - let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { continue } + guard let key = self.normalizeOverrideKey(rawKey) else { continue } let upper = key.uppercased() // PATH is part of the security boundary (command resolution + safe-bin checks). Never // allow request-scoped PATH overrides from agents/gateways. diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index 40db384b226..e45261cda2e 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -63,7 +63,23 @@ enum HostEnvSecurityPolicy { "OPENSSL_ENGINES", "PYTHONSTARTUP", "WGETRC", - "CURL_HOME" + "CURL_HOME", + "CLASSPATH", + "CGO_CFLAGS", + "CGO_LDFLAGS", + "GOFLAGS", + "CORECLR_PROFILER_PATH", + "PHPRC", + "PHP_INI_SCAN_DIR", + "DENO_DIR", + "BUN_CONFIG_REGISTRY", + "LUA_PATH", + "LUA_CPATH", + "GEM_HOME", + "GEM_PATH", + "BUNDLE_GEMFILE", + "COMPOSER_HOME", + "XDG_CONFIG_HOME" ] static let blockedOverridePrefixes: [String] = [ diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index c24f5d0f1b8..956abf94ad6 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -465,6 +465,23 @@ actor MacNodeRuntime { ? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines) : self.mainSessionKey let runId = UUID().uuidString + let envOverrideDiagnostics = HostEnvSanitizer.inspectOverrides( + overrides: params.env, + blockPathOverrides: true) + if !envOverrideDiagnostics.blockedKeys.isEmpty || !envOverrideDiagnostics.invalidKeys.isEmpty { + var details: [String] = [] + if !envOverrideDiagnostics.blockedKeys.isEmpty { + details.append("blocked override keys: \(envOverrideDiagnostics.blockedKeys.joined(separator: ", "))") + } + if !envOverrideDiagnostics.invalidKeys.isEmpty { + details.append( + "invalid non-portable override keys: \(envOverrideDiagnostics.invalidKeys.joined(separator: ", "))") + } + return Self.errorResponse( + req, + code: .invalidRequest, + message: "SYSTEM_RUN_DENIED: environment override rejected (\(details.joined(separator: "; ")))") + } let evaluation = await ExecApprovalEvaluator.evaluate( command: command, rawCommand: params.rawCommand, diff --git a/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift index 1e9da910b2a..55a15419576 100644 --- a/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift @@ -33,4 +33,24 @@ struct HostEnvSanitizerTests { let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"]) #expect(env["OPENCLAW_TOKEN"] == "secret") } + + @Test func `inspect overrides rejects blocked and invalid keys`() { + let diagnostics = HostEnvSanitizer.inspectOverrides(overrides: [ + "CLASSPATH": "/tmp/evil-classpath", + "BAD-KEY": "x", + "ProgramFiles(x86)": "C:\\Program Files (x86)", + ]) + + #expect(diagnostics.blockedKeys == ["CLASSPATH"]) + #expect(diagnostics.invalidKeys == ["BAD-KEY"]) + } + + @Test func `sanitize accepts Windows-style override key names`() { + let env = HostEnvSanitizer.sanitize(overrides: [ + "ProgramFiles(x86)": "D:\\SDKs", + "CommonProgramFiles(x86)": "D:\\Common", + ]) + #expect(env["ProgramFiles(x86)"] == "D:\\SDKs") + #expect(env["CommonProgramFiles(x86)"] == "D:\\Common") + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift index 20b4184f5c9..38c4211f014 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift @@ -21,6 +21,32 @@ struct MacNodeRuntimeTests { #expect(response.ok == false) } + @Test func `handle invoke rejects blocked system run env override before execution`() async throws { + let runtime = MacNodeRuntime() + let params = OpenClawSystemRunParams( + command: ["/bin/sh", "-lc", "echo ok"], + env: ["CLASSPATH": "/tmp/evil-classpath"]) + let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) + let response = await runtime.handleInvoke( + BridgeInvokeRequest(id: "req-2c", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json)) + #expect(response.ok == false) + #expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true) + #expect(response.error?.message.contains("CLASSPATH") == true) + } + + @Test func `handle invoke rejects invalid system run env override key before execution`() async throws { + let runtime = MacNodeRuntime() + let params = OpenClawSystemRunParams( + command: ["/bin/sh", "-lc", "echo ok"], + env: ["BAD-KEY": "x"]) + let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) + let response = await runtime.handleInvoke( + BridgeInvokeRequest(id: "req-2d", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json)) + #expect(response.ok == false) + #expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true) + #expect(response.error?.message.contains("BAD-KEY") == true) + } + @Test func `handle invoke rejects empty system which`() async throws { let runtime = MacNodeRuntime() let params = OpenClawSystemWhichParams(bins: []) diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 766bfe22107..247c21aede9 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -130,6 +130,22 @@ describe("exec PATH login shell merge", () => { expect(shellPathMock).not.toHaveBeenCalled(); }); + it("fails closed when a blocked runtime override key is requested", async () => { + if (isWin) { + return; + } + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + + await expect( + tool.execute("call-blocked-runtime-env", { + command: "echo ok", + env: { CLASSPATH: "/tmp/evil-classpath" }, + }), + ).rejects.toThrow( + /Security Violation: Environment variable 'CLASSPATH' is forbidden during host execution\./, + ); + }); + it("does not apply login-shell PATH when probe rejects unregistered absolute SHELL", async () => { if (isWin) { return; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 5fe0f7deac4..dcb50c0344c 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { type ExecHost, loadExecApprovals, maxAsk, minSecurity } from "../infra/exec-approvals.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; +import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js"; import { getShellPathFromLoginShell, resolveShellEnvFallbackTimeoutMs, @@ -25,9 +26,7 @@ import { renderExecHostLabel, resolveApprovalRunningNoticeMs, runExecProcess, - sanitizeHostBaseEnv, execSchema, - validateHostEnv, } from "./bash-tools.exec-runtime.js"; import type { ExecElevatedDefaults, @@ -362,24 +361,58 @@ export function createExecTool( } const inheritedBaseEnv = coerceEnv(process.env); - const baseEnv = host === "sandbox" ? inheritedBaseEnv : sanitizeHostBaseEnv(inheritedBaseEnv); - - // Logic: Sandbox gets raw env. Host (gateway/node) must pass validation. - // We validate BEFORE merging to prevent any dangerous vars from entering the stream. - if (host !== "sandbox" && params.env) { - validateHostEnv(params.env); + const hostEnvResult = + host === "sandbox" + ? null + : sanitizeHostExecEnvWithDiagnostics({ + baseEnv: inheritedBaseEnv, + overrides: params.env, + blockPathOverrides: true, + }); + if ( + hostEnvResult && + params.env && + (hostEnvResult.rejectedOverrideBlockedKeys.length > 0 || + hostEnvResult.rejectedOverrideInvalidKeys.length > 0) + ) { + const blockedKeys = hostEnvResult.rejectedOverrideBlockedKeys; + const invalidKeys = hostEnvResult.rejectedOverrideInvalidKeys; + const pathBlocked = blockedKeys.includes("PATH"); + if (pathBlocked && blockedKeys.length === 1 && invalidKeys.length === 0) { + throw new Error( + "Security Violation: Custom 'PATH' variable is forbidden during host execution.", + ); + } + if (blockedKeys.length === 1 && invalidKeys.length === 0) { + throw new Error( + `Security Violation: Environment variable '${blockedKeys[0]}' is forbidden during host execution.`, + ); + } + const details: string[] = []; + if (blockedKeys.length > 0) { + details.push(`blocked override keys: ${blockedKeys.join(", ")}`); + } + if (invalidKeys.length > 0) { + details.push(`invalid non-portable override keys: ${invalidKeys.join(", ")}`); + } + const suffix = details.join("; "); + if (pathBlocked) { + throw new Error( + `Security Violation: Custom 'PATH' variable is forbidden during host execution (${suffix}).`, + ); + } + throw new Error(`Security Violation: ${suffix}.`); } - const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv; - - const env = sandbox - ? buildSandboxEnv({ - defaultPath: DEFAULT_PATH, - paramsEnv: params.env, - sandboxEnv: sandbox.env, - containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir, - }) - : mergedEnv; + const env = + sandbox && host === "sandbox" + ? buildSandboxEnv({ + defaultPath: DEFAULT_PATH, + paramsEnv: params.env, + sandboxEnv: sandbox.env, + containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir, + }) + : (hostEnvResult?.env ?? inheritedBaseEnv); if (!sandbox && host === "gateway" && !params.env?.PATH) { const shellPath = getShellPathFromLoginShell({ diff --git a/src/infra/host-env-security-policy.json b/src/infra/host-env-security-policy.json index 785b8e37049..2f6cd25bde6 100644 --- a/src/infra/host-env-security-policy.json +++ b/src/infra/host-env-security-policy.json @@ -56,7 +56,23 @@ "OPENSSL_ENGINES", "PYTHONSTARTUP", "WGETRC", - "CURL_HOME" + "CURL_HOME", + "CLASSPATH", + "CGO_CFLAGS", + "CGO_LDFLAGS", + "GOFLAGS", + "CORECLR_PROFILER_PATH", + "PHPRC", + "PHP_INI_SCAN_DIR", + "DENO_DIR", + "BUN_CONFIG_REGISTRY", + "LUA_PATH", + "LUA_CPATH", + "GEM_HOME", + "GEM_PATH", + "BUNDLE_GEMFILE", + "COMPOSER_HOME", + "XDG_CONFIG_HOME" ], "blockedOverridePrefixes": ["GIT_CONFIG_", "NPM_CONFIG_"], "blockedPrefixes": ["DYLD_", "LD_", "BASH_FUNC_"] diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index cd3edb3e06b..f326a0c75ed 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -8,6 +8,7 @@ import { isDangerousHostEnvVarName, normalizeEnvVarKey, sanitizeHostExecEnv, + sanitizeHostExecEnvWithDiagnostics, sanitizeSystemRunEnvOverrides, } from "./host-env-security.js"; import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js"; @@ -114,6 +115,10 @@ describe("sanitizeHostExecEnv", () => { GIT_CONFIG_GLOBAL: "/tmp/gitconfig", SHELLOPTS: "xtrace", PS4: "$(touch /tmp/pwned)", + CLASSPATH: "/tmp/evil-classpath", + GOFLAGS: "-mod=mod", + PHPRC: "/tmp/evil-php.ini", + XDG_CONFIG_HOME: "/tmp/evil-config", SAFE: "ok", }, }); @@ -128,6 +133,10 @@ describe("sanitizeHostExecEnv", () => { expect(env.GIT_CONFIG_GLOBAL).toBeUndefined(); expect(env.SHELLOPTS).toBeUndefined(); expect(env.PS4).toBeUndefined(); + expect(env.CLASSPATH).toBeUndefined(); + expect(env.GOFLAGS).toBeUndefined(); + expect(env.PHPRC).toBeUndefined(); + expect(env.XDG_CONFIG_HOME).toBeUndefined(); expect(env.SAFE).toBe("ok"); expect(env.HOME).toBe("/tmp/trusted-home"); expect(env.ZDOTDIR).toBe("/tmp/trusted-zdotdir"); @@ -183,7 +192,7 @@ describe("sanitizeHostExecEnv", () => { expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); }); - it("drops non-string inherited values and non-portable inherited keys", () => { + it("drops non-string inherited values while preserving non-portable inherited keys", () => { const env = sanitizeHostExecEnv({ baseEnv: { PATH: "/usr/bin:/bin", @@ -191,6 +200,7 @@ describe("sanitizeHostExecEnv", () => { // oxlint-disable-next-line typescript/no-explicit-any BAD_NUMBER: 1 as any, "NOT-PORTABLE": "x", + "ProgramFiles(x86)": "C:\\Program Files (x86)", }, }); @@ -198,6 +208,8 @@ describe("sanitizeHostExecEnv", () => { OPENCLAW_CLI: OPENCLAW_CLI_ENV_VALUE, PATH: "/usr/bin:/bin", GOOD: "1", + "NOT-PORTABLE": "x", + "ProgramFiles(x86)": "C:\\Program Files (x86)", }); }); }); @@ -212,11 +224,58 @@ describe("isDangerousHostEnvOverrideVarName", () => { expect(isDangerousHostEnvOverrideVarName("git_config_global")).toBe(true); expect(isDangerousHostEnvOverrideVarName("GRADLE_USER_HOME")).toBe(true); expect(isDangerousHostEnvOverrideVarName("gradle_user_home")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("CLASSPATH")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("classpath")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("GOFLAGS")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("goflags")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("CORECLR_PROFILER_PATH")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("coreclr_profiler_path")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("XDG_CONFIG_HOME")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("xdg_config_home")).toBe(true); expect(isDangerousHostEnvOverrideVarName("BASH_ENV")).toBe(false); expect(isDangerousHostEnvOverrideVarName("FOO")).toBe(false); }); }); +describe("sanitizeHostExecEnvWithDiagnostics", () => { + it("reports blocked and invalid requested overrides", () => { + const result = sanitizeHostExecEnvWithDiagnostics({ + baseEnv: { + PATH: "/usr/bin:/bin", + }, + overrides: { + PATH: "/tmp/evil", + CLASSPATH: "/tmp/evil-classpath", + SAFE_KEY: "ok", + "BAD-KEY": "bad", + }, + }); + + expect(result.rejectedOverrideBlockedKeys).toEqual(["CLASSPATH", "PATH"]); + expect(result.rejectedOverrideInvalidKeys).toEqual(["BAD-KEY"]); + expect(result.env.SAFE_KEY).toBe("ok"); + expect(result.env.PATH).toBe("/usr/bin:/bin"); + expect(result.env.CLASSPATH).toBeUndefined(); + }); + + it("allows Windows-style override names while still rejecting invalid keys", () => { + const result = sanitizeHostExecEnvWithDiagnostics({ + baseEnv: { + PATH: "/usr/bin:/bin", + "ProgramFiles(x86)": "C:\\Program Files (x86)", + }, + overrides: { + "ProgramFiles(x86)": "D:\\SDKs", + "BAD-KEY": "bad", + }, + }); + + expect(result.rejectedOverrideBlockedKeys).toEqual([]); + expect(result.rejectedOverrideInvalidKeys).toEqual(["BAD-KEY"]); + expect(result.env["ProgramFiles(x86)"]).toBe("D:\\SDKs"); + }); +}); + describe("normalizeEnvVarKey", () => { it("normalizes and validates keys", () => { expect(normalizeEnvVarKey(" OPENROUTER_API_KEY ")).toBe("OPENROUTER_API_KEY"); diff --git a/src/infra/host-env-security.ts b/src/infra/host-env-security.ts index 11d6b8e9f3c..c6ac3dded61 100644 --- a/src/infra/host-env-security.ts +++ b/src/infra/host-env-security.ts @@ -2,6 +2,7 @@ import HOST_ENV_SECURITY_POLICY_JSON from "./host-env-security-policy.json" with import { markOpenClawExecEnv } from "./openclaw-exec-env.js"; const PORTABLE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; +const WINDOWS_COMPAT_OVERRIDE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_()]*$/; type HostEnvSecurityPolicy = { blockedKeys: string[]; @@ -42,6 +43,17 @@ export const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS = new Set( HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES, ); +export type HostExecEnvSanitizationResult = { + env: Record; + rejectedOverrideBlockedKeys: string[]; + rejectedOverrideInvalidKeys: string[]; +}; + +export type HostExecEnvOverrideDiagnostics = { + rejectedOverrideBlockedKeys: string[]; + rejectedOverrideInvalidKeys: string[]; +}; + export function normalizeEnvVarKey( rawKey: string, options?: { portable?: boolean }, @@ -56,6 +68,17 @@ export function normalizeEnvVarKey( return key; } +function normalizeHostOverrideEnvVarKey(rawKey: string): string | null { + const key = normalizeEnvVarKey(rawKey); + if (!key) { + return null; + } + if (PORTABLE_ENV_VAR_KEY.test(key) || WINDOWS_COMPAT_OVERRIDE_ENV_VAR_KEY.test(key)) { + return key; + } + return null; +} + export function isDangerousHostEnvVarName(rawKey: string): boolean { const key = normalizeEnvVarKey(rawKey); if (!key) { @@ -80,15 +103,16 @@ export function isDangerousHostEnvOverrideVarName(rawKey: string): boolean { return HOST_DANGEROUS_OVERRIDE_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix)); } -function listNormalizedPortableEnvEntries( +function listNormalizedEnvEntries( source: Record, + options?: { portable?: boolean }, ): Array<[string, string]> { const entries: Array<[string, string]> = []; for (const [rawKey, value] of Object.entries(source)) { if (typeof value !== "string") { continue; } - const key = normalizeEnvVarKey(rawKey, { portable: true }); + const key = normalizeEnvVarKey(rawKey, options); if (!key) { continue; } @@ -97,41 +121,112 @@ function listNormalizedPortableEnvEntries( return entries; } -export function sanitizeHostExecEnv(params?: { +function sortUnique(values: Iterable): string[] { + return Array.from(new Set(values)).toSorted((a, b) => a.localeCompare(b)); +} + +function sanitizeHostEnvOverridesWithDiagnostics(params?: { + overrides?: Record | null; + blockPathOverrides?: boolean; +}): { + acceptedOverrides?: Record; + rejectedOverrideBlockedKeys: string[]; + rejectedOverrideInvalidKeys: string[]; +} { + const overrides = params?.overrides ?? undefined; + if (!overrides) { + return { + acceptedOverrides: undefined, + rejectedOverrideBlockedKeys: [], + rejectedOverrideInvalidKeys: [], + }; + } + + const blockPathOverrides = params?.blockPathOverrides ?? true; + const acceptedOverrides: Record = {}; + const rejectedBlocked: string[] = []; + const rejectedInvalid: string[] = []; + + for (const [rawKey, value] of Object.entries(overrides)) { + if (typeof value !== "string") { + continue; + } + const normalized = normalizeHostOverrideEnvVarKey(rawKey); + if (!normalized) { + const candidate = rawKey.trim(); + rejectedInvalid.push(candidate || rawKey); + continue; + } + const upper = normalized.toUpperCase(); + // PATH is part of the security boundary (command resolution + safe-bin checks). Never allow + // request-scoped PATH overrides from agents/gateways. + if (blockPathOverrides && upper === "PATH") { + rejectedBlocked.push(upper); + continue; + } + if (isDangerousHostEnvVarName(upper) || isDangerousHostEnvOverrideVarName(upper)) { + rejectedBlocked.push(upper); + continue; + } + acceptedOverrides[normalized] = value; + } + + return { + acceptedOverrides, + rejectedOverrideBlockedKeys: sortUnique(rejectedBlocked), + rejectedOverrideInvalidKeys: sortUnique(rejectedInvalid), + }; +} + +export function sanitizeHostExecEnvWithDiagnostics(params?: { baseEnv?: Record; overrides?: Record | null; blockPathOverrides?: boolean; -}): Record { +}): HostExecEnvSanitizationResult { const baseEnv = params?.baseEnv ?? process.env; - const overrides = params?.overrides ?? undefined; - const blockPathOverrides = params?.blockPathOverrides ?? true; const merged: Record = {}; - for (const [key, value] of listNormalizedPortableEnvEntries(baseEnv)) { + for (const [key, value] of listNormalizedEnvEntries(baseEnv)) { if (isDangerousHostEnvVarName(key)) { continue; } merged[key] = value; } - if (!overrides) { - return markOpenClawExecEnv(merged); + const overrideResult = sanitizeHostEnvOverridesWithDiagnostics({ + overrides: params?.overrides ?? undefined, + blockPathOverrides: params?.blockPathOverrides ?? true, + }); + if (overrideResult.acceptedOverrides) { + for (const [key, value] of Object.entries(overrideResult.acceptedOverrides)) { + merged[key] = value; + } } - for (const [key, value] of listNormalizedPortableEnvEntries(overrides)) { - const upper = key.toUpperCase(); - // PATH is part of the security boundary (command resolution + safe-bin checks). Never allow - // request-scoped PATH overrides from agents/gateways. - if (blockPathOverrides && upper === "PATH") { - continue; - } - if (isDangerousHostEnvVarName(upper) || isDangerousHostEnvOverrideVarName(upper)) { - continue; - } - merged[key] = value; - } + return { + env: markOpenClawExecEnv(merged), + rejectedOverrideBlockedKeys: overrideResult.rejectedOverrideBlockedKeys, + rejectedOverrideInvalidKeys: overrideResult.rejectedOverrideInvalidKeys, + }; +} - return markOpenClawExecEnv(merged); +export function inspectHostExecEnvOverrides(params?: { + overrides?: Record | null; + blockPathOverrides?: boolean; +}): HostExecEnvOverrideDiagnostics { + const result = sanitizeHostEnvOverridesWithDiagnostics(params); + return { + rejectedOverrideBlockedKeys: result.rejectedOverrideBlockedKeys, + rejectedOverrideInvalidKeys: result.rejectedOverrideInvalidKeys, + }; +} + +export function sanitizeHostExecEnv(params?: { + baseEnv?: Record; + overrides?: Record | null; + blockPathOverrides?: boolean; +}): Record { + return sanitizeHostExecEnvWithDiagnostics(params).env; } export function sanitizeSystemRunEnvOverrides(params?: { @@ -146,7 +241,7 @@ export function sanitizeSystemRunEnvOverrides(params?: { return overrides; } const filtered: Record = {}; - for (const [key, value] of listNormalizedPortableEnvEntries(overrides)) { + for (const [key, value] of listNormalizedEnvEntries(overrides, { portable: true })) { if (!HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS.has(key.toUpperCase())) { continue; } diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 045897a5fc4..02457b98b4d 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -336,6 +336,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { preferMacAppExecHost: boolean; runViaResponse?: ExecHostResponse | null; command?: string[]; + env?: Record; rawCommand?: string | null; systemRunPlan?: SystemRunApprovalPlan | null; cwd?: string; @@ -391,6 +392,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { client: {} as never, params: { command: params.command ?? ["echo", "ok"], + env: params.env, rawCommand: params.rawCommand, systemRunPlan: params.systemRunPlan, cwd: params.cwd, @@ -1106,6 +1108,65 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult }); }); + it("rejects blocked environment overrides before execution", async () => { + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + security: "full", + ask: "off", + env: { CLASSPATH: "/tmp/evil-classpath" }, + }); + + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: environment override rejected", + }); + expectInvokeErrorMessage(sendInvokeResult, { + message: "CLASSPATH", + }); + }); + + it("rejects blocked environment overrides for shell-wrapper commands", async () => { + const shellCommand = + process.platform === "win32" + ? ["cmd.exe", "/d", "/s", "/c", "echo ok"] + : ["/bin/sh", "-lc", "echo ok"]; + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + security: "full", + ask: "off", + command: shellCommand, + env: { + CLASSPATH: "/tmp/evil-classpath", + LANG: "C", + }, + }); + + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: environment override rejected", + }); + expectInvokeErrorMessage(sendInvokeResult, { + message: "CLASSPATH", + }); + }); + + it("rejects invalid non-portable environment override keys before execution", async () => { + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + security: "full", + ask: "off", + env: { "BAD-KEY": "x" }, + }); + + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: environment override rejected", + }); + expectInvokeErrorMessage(sendInvokeResult, { + message: "BAD-KEY", + }); + }); + async function expectNestedEnvShellDenied(params: { depth: number; markerName: string; diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index c38094dc683..b530b980840 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -14,7 +14,10 @@ import { } from "../infra/exec-approvals.js"; import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; -import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js"; +import { + inspectHostExecEnvOverrides, + sanitizeSystemRunEnvOverrides, +} from "../infra/host-env-security.js"; import { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-binding.js"; import { resolveSystemRunCommandRequest } from "../infra/system-run-command.js"; import { logWarn } from "../logger.js"; @@ -244,6 +247,34 @@ async function parseSystemRunPhase( const sessionKey = opts.params.sessionKey?.trim() || "node"; const runId = opts.params.runId?.trim() || crypto.randomUUID(); const suppressNotifyOnExit = opts.params.suppressNotifyOnExit === true; + const envOverrideDiagnostics = inspectHostExecEnvOverrides({ + overrides: opts.params.env ?? undefined, + blockPathOverrides: true, + }); + if ( + envOverrideDiagnostics.rejectedOverrideBlockedKeys.length > 0 || + envOverrideDiagnostics.rejectedOverrideInvalidKeys.length > 0 + ) { + const details: string[] = []; + if (envOverrideDiagnostics.rejectedOverrideBlockedKeys.length > 0) { + details.push( + `blocked override keys: ${envOverrideDiagnostics.rejectedOverrideBlockedKeys.join(", ")}`, + ); + } + if (envOverrideDiagnostics.rejectedOverrideInvalidKeys.length > 0) { + details.push( + `invalid non-portable override keys: ${envOverrideDiagnostics.rejectedOverrideInvalidKeys.join(", ")}`, + ); + } + await opts.sendInvokeResult({ + ok: false, + error: { + code: "INVALID_REQUEST", + message: `SYSTEM_RUN_DENIED: environment override rejected (${details.join("; ")})`, + }, + }); + return null; + } const envOverrides = sanitizeSystemRunEnvOverrides({ overrides: opts.params.env ?? undefined, shellWrapper: shellPayload !== null, diff --git a/src/node-host/invoke.sanitize-env.test.ts b/src/node-host/invoke.sanitize-env.test.ts index aa55a24047e..c53d7b08953 100644 --- a/src/node-host/invoke.sanitize-env.test.ts +++ b/src/node-host/invoke.sanitize-env.test.ts @@ -51,6 +51,13 @@ describe("node-host sanitizeEnv", () => { expect(env.BASH_ENV).toBeUndefined(); }); }); + + it("preserves inherited non-portable Windows-style env keys", () => { + withEnv({ "ProgramFiles(x86)": "C:\\Program Files (x86)" }, () => { + const env = sanitizeEnv(undefined); + expect(env["ProgramFiles(x86)"]).toBe("C:\\Program Files (x86)"); + }); + }); }); describe("node-host output decoding", () => { From 09cf6d80ec8948d25fae2b331267803e2a8514ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 20:43:32 +0000 Subject: [PATCH 2/9] test: batch thread-only unit lanes --- scripts/test-parallel.mjs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index f3c03970080..6100e99f42f 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -437,6 +437,22 @@ const unitSingletonEntries = unitSingletonBuckets.map((files, index) => ({ unitSingletonBuckets.length === 1 ? "unit-singleton" : `unit-singleton-${String(index + 1)}`, args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files], })); +const unitThreadEntries = + unitThreadSingletonFiles.length > 0 + ? [ + { + name: "unit-threads", + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + "--pool=threads", + ...unitThreadSingletonFiles, + ], + }, + ] + : []; const baseRuns = [ ...(shouldSplitUnitRuns ? [ @@ -469,10 +485,7 @@ const baseRuns = [ file, ], })), - ...unitThreadSingletonFiles.map((file) => ({ - name: `${path.basename(file, ".test.ts")}-threads`, - args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=threads", file], - })), + ...unitThreadEntries, ...unitVmForkSingletonFiles.map((file) => ({ name: `${path.basename(file, ".test.ts")}-vmforks`, args: [ From aed1f6d807915dd41cbe409f051480bde798d093 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 21:07:56 +0000 Subject: [PATCH 3/9] test: parallelize low-profile deferred lanes --- scripts/test-parallel.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 6100e99f42f..10ca1f5e0f4 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -1300,9 +1300,16 @@ if (serialPrefixRuns.length > 0) { if (failedSerialPrefix !== undefined) { process.exit(failedSerialPrefix); } + const deferredRunConcurrency = isMacMiniProfile ? 3 : testProfile === "low" ? 2 : undefined; const failedDeferredParallel = isMacMiniProfile - ? await runEntriesWithLimit(deferredParallelRuns, passthroughOptionArgs, 3) - : await runEntries(deferredParallelRuns, passthroughOptionArgs); + ? await runEntriesWithLimit(deferredParallelRuns, passthroughOptionArgs, deferredRunConcurrency) + : deferredRunConcurrency + ? await runEntriesWithLimit( + deferredParallelRuns, + passthroughOptionArgs, + deferredRunConcurrency, + ) + : await runEntries(deferredParallelRuns, passthroughOptionArgs); if (failedDeferredParallel !== undefined) { process.exit(failedDeferredParallel); } From 994b42a5a550a98e80b724c462656cfaed376d9e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 21:16:01 +0000 Subject: [PATCH 4/9] test: parallelize safe audit case tables --- src/security/audit.test.ts | 110 +++++++++++++++++++++---------------- 1 file changed, 62 insertions(+), 48 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 6a8e72f6f2e..449fe82045c 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1100,29 +1100,29 @@ description: test skill }, ] as const; - for (const testCase of cases) { - if (!testCase.supported) { - continue; - } + await Promise.all( + cases + .filter((testCase) => testCase.supported) + .map(async (testCase) => { + const fixture = await testCase.setup(); + const configPath = path.join(fixture.stateDir, "openclaw.json"); + await fs.writeFile(configPath, "{}\n", "utf-8"); + if (!isWindows) { + await fs.chmod(configPath, 0o600); + } - const fixture = await testCase.setup(); - const configPath = path.join(fixture.stateDir, "openclaw.json"); - await fs.writeFile(configPath, "{}\n", "utf-8"); - if (!isWindows) { - await fs.chmod(configPath, 0o600); - } + const res = await runSecurityAudit({ + config: { agents: { defaults: { workspace: fixture.workspaceDir } } }, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir: fixture.stateDir, + configPath, + execDockerRawFn: execDockerRawUnavailable, + }); - const res = await runSecurityAudit({ - config: { agents: { defaults: { workspace: fixture.workspaceDir } } }, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir: fixture.stateDir, - configPath, - execDockerRawFn: execDockerRawUnavailable, - }); - - testCase.assert(res, fixture); - } + testCase.assert(res, fixture); + }), + ); }); it("scores small-model risk by tool/sandbox exposure", async () => { @@ -1554,20 +1554,24 @@ description: test skill }, ] as const; - for (const testCase of cases) { - const res = await audit(testCase.cfg); - if ("expectedFinding" in testCase) { - expect(res.findings, testCase.name).toEqual( - expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + if ("expectedFinding" in testCase) { + expect(res.findings, testCase.name).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), + ); + } + const finding = res.findings.find( + (f) => f.checkId === "config.insecure_or_dangerous_flags", ); - } - const finding = res.findings.find((f) => f.checkId === "config.insecure_or_dangerous_flags"); - expect(finding, testCase.name).toBeTruthy(); - expect(finding?.severity, testCase.name).toBe("warn"); - for (const detail of testCase.expectedDangerousDetails) { - expect(finding?.detail, `${testCase.name}:${detail}`).toContain(detail); - } - } + expect(finding, testCase.name).toBeTruthy(); + expect(finding?.severity, testCase.name).toBe("warn"); + for (const detail of testCase.expectedDangerousDetails) { + expect(finding?.detail, `${testCase.name}:${detail}`).toContain(detail); + } + }), + ); }); it.each([ @@ -3116,17 +3120,19 @@ description: test skill }, ] as const; - for (const testCase of cases) { - const res = await testCase.run(); - const expectedPresent = "expectedPresent" in testCase ? testCase.expectedPresent : []; - for (const checkId of expectedPresent) { - expect(hasFinding(res, checkId, "warn"), `${testCase.name}:${checkId}`).toBe(true); - } - const expectedAbsent = "expectedAbsent" in testCase ? testCase.expectedAbsent : []; - for (const checkId of expectedAbsent) { - expect(hasFinding(res, checkId), `${testCase.name}:${checkId}`).toBe(false); - } - } + await Promise.all( + cases.map(async (testCase) => { + const res = await testCase.run(); + const expectedPresent = "expectedPresent" in testCase ? testCase.expectedPresent : []; + for (const checkId of expectedPresent) { + expect(hasFinding(res, checkId, "warn"), `${testCase.name}:${checkId}`).toBe(true); + } + const expectedAbsent = "expectedAbsent" in testCase ? testCase.expectedAbsent : []; + for (const checkId of expectedAbsent) { + expect(hasFinding(res, checkId), `${testCase.name}:${checkId}`).toBe(false); + } + }), + ); }); it("evaluates extension tool reachability findings", async () => { @@ -3339,9 +3345,17 @@ description: test skill }, ] as const; - for (const testCase of cases) { - const result = await testCase.run(); - testCase.assert(result as never); + await Promise.all( + cases.slice(0, -1).map(async (testCase) => { + const result = await testCase.run(); + testCase.assert(result as never); + }), + ); + + const scanFailureCase = cases.at(-1); + if (scanFailureCase) { + const result = await scanFailureCase.run(); + scanFailureCase.assert(result as never); } }); From cadbaa34c102b079be42708c79064807bfc5e2a7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 21:30:44 +0000 Subject: [PATCH 5/9] test: widen low-profile scheduler peeling --- scripts/test-parallel.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 10ca1f5e0f4..d3a7c88b5de 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -297,7 +297,7 @@ const defaultHeavyUnitFileLimit = : isMacMiniProfile ? 90 : testProfile === "low" - ? 32 + ? 36 : highMemLocalHost ? 80 : 60; @@ -307,7 +307,7 @@ const defaultHeavyUnitLaneCount = : isMacMiniProfile ? 6 : testProfile === "low" - ? 3 + ? 4 : highMemLocalHost ? 5 : 4; @@ -708,7 +708,9 @@ const defaultTopLevelParallelLimit = testProfile === "serial" ? 1 : testProfile === "low" - ? 2 + ? lowMemLocalHost + ? 2 + : 3 : testProfile === "max" ? 5 : highMemLocalHost From c3972982b5d47e8250ca2b1a64a25b373d9c1f2f Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Fri, 20 Mar 2026 15:03:30 -0700 Subject: [PATCH 6/9] fix: sanitize malformed replay tool calls (#50005) Merged via squash. Prepared head SHA: 64ad5563f7ae321b749d5a52bc0b477d666dc6be Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + .../pi-embedded-runner/run/attempt.test.ts | 547 ++++++++++++++++++ src/agents/pi-embedded-runner/run/attempt.ts | 237 ++++++++ src/agents/session-transcript-repair.ts | 50 +- .../bundled-web-search-registry.ts | 16 +- src/plugins/bundled-web-search.ts | 2 +- src/plugins/contracts/registry.ts | 2 +- 7 files changed, 830 insertions(+), 25 deletions(-) rename src/{plugins => }/bundled-web-search-registry.ts (56%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f533794769..210ce179a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -187,6 +187,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction: add an opt-in post-compaction session JSONL truncation step that drops summarized transcript entries while preserving the retained branch tail and live session metadata. (#41021) thanks @thirumaleshp. - Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys. - Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras. +- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman. ### Breaking diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 20bf752587b..39b2abe4da7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -16,6 +16,7 @@ import { decodeHtmlEntitiesInObject, wrapOllamaCompatNumCtx, wrapStreamFnRepairMalformedToolCallArguments, + wrapStreamFnSanitizeMalformedToolCalls, wrapStreamFnTrimToolCallNames, } from "./attempt.js"; @@ -779,6 +780,552 @@ describe("wrapStreamFnTrimToolCallNames", () => { }); }); +describe("wrapStreamFnSanitizeMalformedToolCalls", () => { + it("drops malformed assistant tool calls from outbound context before provider replay", async () => { + const messages = [ + { + role: "assistant", + stopReason: "error", + content: [{ type: "toolCall", name: "read", arguments: {} }], + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toEqual([ + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]); + expect(seenContext.messages).not.toBe(messages); + }); + + it("preserves outbound context when all assistant tool calls are valid", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toBe(messages); + }); + + it("preserves sessions_spawn attachment payloads on replay", async () => { + const attachmentContent = "INLINE_ATTACHMENT_PAYLOAD"; + const messages = [ + { + role: "assistant", + content: [ + { + type: "toolUse", + id: "call_1", + name: " SESSIONS_SPAWN ", + input: { + task: "inspect attachment", + attachments: [{ name: "snapshot.txt", content: attachmentContent }], + }, + }, + ], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls( + baseFn as never, + new Set(["sessions_spawn"]), + ); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ content?: Array> }>; + }; + const toolCall = seenContext.messages[0]?.content?.[0] as { + name?: string; + input?: { attachments?: Array<{ content?: string }> }; + }; + expect(toolCall.name).toBe("sessions_spawn"); + expect(toolCall.input?.attachments?.[0]?.content).toBe(attachmentContent); + }); + + it("preserves allowlisted tool names that contain punctuation", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolUse", id: "call_1", name: "admin.export", input: { scope: "all" } }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls( + baseFn as never, + new Set(["admin.export"]), + ); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toBe(messages); + }); + + it("normalizes provider-prefixed replayed tool names before provider replay", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolUse", id: "call_1", name: "functions.read", input: { path: "." } }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ content?: Array<{ name?: string }> }>; + }; + expect(seenContext.messages[0]?.content?.[0]?.name).toBe("read"); + }); + + it("canonicalizes mixed-case allowlisted tool names on replay", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "readfile", arguments: {} }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["ReadFile"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ content?: Array<{ name?: string }> }>; + }; + expect(seenContext.messages[0]?.content?.[0]?.name).toBe("ReadFile"); + }); + + it("recovers blank replayed tool names from their ids", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "functionswrite4", name: " ", arguments: {} }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["write"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ content?: Array<{ name?: string }> }>; + }; + expect(seenContext.messages[0]?.content?.[0]?.name).toBe("write"); + }); + + it("recovers mangled replayed tool names before dropping the call", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "functionsread3", arguments: {} }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ content?: Array<{ name?: string }> }>; + }; + expect(seenContext.messages[0]?.content?.[0]?.name).toBe("read"); + }); + + it("drops orphaned tool results after replay sanitization removes a tool-call turn", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", name: "read", arguments: {} }], + stopReason: "error", + }, + { + role: "toolResult", + toolCallId: "call_missing", + toolName: "read", + content: [{ type: "text", text: "stale result" }], + isError: false, + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ role?: string }>; + }; + expect(seenContext.messages).toEqual([ + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]); + }); + + it("drops replayed tool calls that are no longer allowlisted", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "write", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "write", + content: [{ type: "text", text: "stale result" }], + isError: false, + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ role?: string }>; + }; + expect(seenContext.messages).toEqual([ + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]); + }); + it("drops replayed tool names that are no longer allowlisted", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolUse", id: "call_1", name: "unknown_tool", input: { path: "." } }], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "unknown_tool", + content: [{ type: "text", text: "stale result" }], + isError: false, + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toEqual([]); + }); + + it("drops ambiguous mangled replay names instead of guessing a tool", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "functions.exec2", arguments: {} }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls( + baseFn as never, + new Set(["exec", "exec2"]), + ); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toEqual([]); + }); + + it("preserves matching tool results for retained errored assistant turns", async () => { + const messages = [ + { + role: "assistant", + stopReason: "error", + content: [ + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + { type: "toolCall", name: "read", arguments: {} }, + ], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "kept result" }], + isError: false, + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toEqual([ + { + role: "assistant", + stopReason: "error", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "kept result" }], + isError: false, + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]); + }); + + it("revalidates turn ordering after dropping an assistant replay turn", async () => { + const messages = [ + { + role: "user", + content: [{ type: "text", text: "first" }], + }, + { + role: "assistant", + stopReason: "error", + content: [{ type: "toolCall", name: "read", arguments: {} }], + }, + { + role: "user", + content: [{ type: "text", text: "second" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), { + validateGeminiTurns: false, + validateAnthropicTurns: true, + }); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ role?: string; content?: unknown[] }>; + }; + expect(seenContext.messages).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }, + ]); + }); + + it("drops orphaned Anthropic user tool_result blocks after replay sanitization", async () => { + const messages = [ + { + role: "assistant", + content: [ + { type: "text", text: "partial response" }, + { type: "toolUse", name: "read", input: { path: "." } }, + ], + }, + { + role: "user", + content: [ + { type: "toolResult", toolUseId: "call_1", content: [{ type: "text", text: "stale" }] }, + { type: "text", text: "retry" }, + ], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), { + validateGeminiTurns: false, + validateAnthropicTurns: true, + }); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ role?: string; content?: unknown[] }>; + }; + expect(seenContext.messages).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "partial response" }], + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]); + }); + + it("drops orphaned Anthropic user tool_result blocks after dropping an assistant replay turn", async () => { + const messages = [ + { + role: "user", + content: [{ type: "text", text: "first" }], + }, + { + role: "assistant", + stopReason: "error", + content: [{ type: "toolUse", name: "read", input: { path: "." } }], + }, + { + role: "user", + content: [ + { type: "toolResult", toolUseId: "call_1", content: [{ type: "text", text: "stale" }] }, + { type: "text", text: "second" }, + ], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), { + validateGeminiTurns: false, + validateAnthropicTurns: true, + }); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ role?: string; content?: unknown[] }>; + }; + expect(seenContext.messages).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }, + ]); + }); +}); + describe("wrapStreamFnRepairMalformedToolCallArguments", () => { async function invokeWrappedStream(baseFn: (...args: never[]) => unknown) { return await invokeWrappedTestStream( diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index c7c7a728ae7..0ef91481415 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -97,6 +97,7 @@ import { buildSystemPromptReport } from "../../system-prompt-report.js"; import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js"; import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js"; import { normalizeToolName } from "../../tool-policy.js"; +import type { TranscriptPolicy } from "../../transcript-policy.js"; import { resolveTranscriptPolicy } from "../../transcript-policy.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { isRunnerAbortError } from "../abort.js"; @@ -648,6 +649,200 @@ function isToolCallBlockType(type: unknown): boolean { return type === "toolCall" || type === "toolUse" || type === "functionCall"; } +const REPLAY_TOOL_CALL_NAME_MAX_CHARS = 64; + +type ReplayToolCallBlock = { + type?: unknown; + id?: unknown; + name?: unknown; + input?: unknown; + arguments?: unknown; +}; + +type ReplayToolCallSanitizeReport = { + messages: AgentMessage[]; + droppedAssistantMessages: number; +}; + +type AnthropicToolResultContentBlock = { + type?: unknown; + toolUseId?: unknown; +}; + +function isReplayToolCallBlock(block: unknown): block is ReplayToolCallBlock { + if (!block || typeof block !== "object") { + return false; + } + return isToolCallBlockType((block as { type?: unknown }).type); +} + +function replayToolCallHasInput(block: ReplayToolCallBlock): boolean { + const hasInput = "input" in block ? block.input !== undefined && block.input !== null : false; + const hasArguments = + "arguments" in block ? block.arguments !== undefined && block.arguments !== null : false; + return hasInput || hasArguments; +} + +function replayToolCallNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function resolveReplayToolCallName( + rawName: string, + rawId: string, + allowedToolNames?: Set, +): string | null { + if (rawName.length > REPLAY_TOOL_CALL_NAME_MAX_CHARS * 2) { + return null; + } + const normalized = normalizeToolCallNameForDispatch(rawName, allowedToolNames, rawId); + const trimmed = normalized.trim(); + if (!trimmed || trimmed.length > REPLAY_TOOL_CALL_NAME_MAX_CHARS || /\s/.test(trimmed)) { + return null; + } + if (!allowedToolNames || allowedToolNames.size === 0) { + return trimmed; + } + return resolveExactAllowedToolName(trimmed, allowedToolNames); +} + +function sanitizeReplayToolCallInputs( + messages: AgentMessage[], + allowedToolNames?: Set, +): ReplayToolCallSanitizeReport { + let changed = false; + let droppedAssistantMessages = 0; + const out: AgentMessage[] = []; + + for (const message of messages) { + if (!message || typeof message !== "object" || message.role !== "assistant") { + out.push(message); + continue; + } + if (!Array.isArray(message.content)) { + out.push(message); + continue; + } + + const nextContent: typeof message.content = []; + let messageChanged = false; + + for (const block of message.content) { + if (!isReplayToolCallBlock(block)) { + nextContent.push(block); + continue; + } + const replayBlock = block as ReplayToolCallBlock; + + if (!replayToolCallHasInput(replayBlock) || !replayToolCallNonEmptyString(replayBlock.id)) { + changed = true; + messageChanged = true; + continue; + } + + const rawName = typeof replayBlock.name === "string" ? replayBlock.name : ""; + const resolvedName = resolveReplayToolCallName(rawName, replayBlock.id, allowedToolNames); + if (!resolvedName) { + changed = true; + messageChanged = true; + continue; + } + + if (replayBlock.name !== resolvedName) { + nextContent.push({ ...(block as object), name: resolvedName } as typeof block); + changed = true; + messageChanged = true; + continue; + } + nextContent.push(block); + } + + if (messageChanged) { + changed = true; + if (nextContent.length > 0) { + out.push({ ...message, content: nextContent }); + } else { + droppedAssistantMessages += 1; + } + continue; + } + + out.push(message); + } + + return { + messages: changed ? out : messages, + droppedAssistantMessages, + }; +} + +function sanitizeAnthropicReplayToolResults(messages: AgentMessage[]): AgentMessage[] { + let changed = false; + const out: AgentMessage[] = []; + + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (!message || typeof message !== "object" || message.role !== "user") { + out.push(message); + continue; + } + if (!Array.isArray(message.content)) { + out.push(message); + continue; + } + + const previous = messages[index - 1]; + const validToolUseIds = new Set(); + if (previous && typeof previous === "object" && previous.role === "assistant") { + const previousContent = (previous as { content?: unknown }).content; + if (Array.isArray(previousContent)) { + for (const block of previousContent) { + if (!block || typeof block !== "object") { + continue; + } + const typedBlock = block as { type?: unknown; id?: unknown }; + if (typedBlock.type !== "toolUse" || typeof typedBlock.id !== "string") { + continue; + } + const trimmedId = typedBlock.id.trim(); + if (trimmedId) { + validToolUseIds.add(trimmedId); + } + } + } + } + + const nextContent = message.content.filter((block) => { + if (!block || typeof block !== "object") { + return true; + } + const typedBlock = block as AnthropicToolResultContentBlock; + if (typedBlock.type !== "toolResult" || typeof typedBlock.toolUseId !== "string") { + return true; + } + return validToolUseIds.size > 0 && validToolUseIds.has(typedBlock.toolUseId); + }); + + if (nextContent.length === message.content.length) { + out.push(message); + continue; + } + + changed = true; + if (nextContent.length > 0) { + out.push({ ...message, content: nextContent }); + continue; + } + + out.push({ + ...message, + content: [{ type: "text", text: "[tool results omitted]" }], + } as AgentMessage); + } + + return changed ? out : messages; +} + function normalizeToolCallIdsInMessage(message: unknown): void { if (!message || typeof message !== "object") { return; @@ -796,6 +991,43 @@ export function wrapStreamFnTrimToolCallNames( }; } +export function wrapStreamFnSanitizeMalformedToolCalls( + baseFn: StreamFn, + allowedToolNames?: Set, + transcriptPolicy?: Pick, +): StreamFn { + return (model, context, options) => { + const ctx = context as unknown as { messages?: unknown }; + const messages = ctx?.messages; + if (!Array.isArray(messages)) { + return baseFn(model, context, options); + } + const sanitized = sanitizeReplayToolCallInputs(messages as AgentMessage[], allowedToolNames); + if (sanitized.messages === messages) { + return baseFn(model, context, options); + } + let nextMessages = sanitizeToolUseResultPairing(sanitized.messages, { + preserveErroredAssistantResults: true, + }); + if (transcriptPolicy?.validateAnthropicTurns) { + nextMessages = sanitizeAnthropicReplayToolResults(nextMessages); + } + if (sanitized.droppedAssistantMessages > 0 || transcriptPolicy?.validateAnthropicTurns) { + if (transcriptPolicy?.validateGeminiTurns) { + nextMessages = validateGeminiTurns(nextMessages); + } + if (transcriptPolicy?.validateAnthropicTurns) { + nextMessages = validateAnthropicTurns(nextMessages); + } + } + const nextContext = { + ...(context as unknown as Record), + messages: nextMessages, + } as unknown; + return baseFn(model, nextContext as typeof context, options); + }; +} + function extractBalancedJsonPrefix(raw: string): string | null { let start = 0; while (start < raw.length && /\s/.test(raw[start] ?? "")) { @@ -2100,6 +2332,11 @@ export async function runEmbeddedAttempt( // Some models emit tool names with surrounding whitespace (e.g. " read "). // pi-agent-core dispatches tool calls with exact string matching, so normalize // names on the live response stream before tool execution. + activeSession.agent.streamFn = wrapStreamFnSanitizeMalformedToolCalls( + activeSession.agent.streamFn, + allowedToolNames, + transcriptPolicy, + ); activeSession.agent.streamFn = wrapStreamFnTrimToolCallNames( activeSession.agent.streamFn, allowedToolNames, diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index e7ab7db94b3..9455837d930 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -195,6 +195,10 @@ export type ToolCallInputRepairOptions = { allowedToolNames?: Iterable; }; +export type ToolUseResultPairingOptions = { + preserveErroredAssistantResults?: boolean; +}; + export function stripToolResultDetails(messages: AgentMessage[]): AgentMessage[] { let touched = false; const out: AgentMessage[] = []; @@ -327,8 +331,11 @@ export function sanitizeToolCallInputs( return repairToolCallInputs(messages, options).messages; } -export function sanitizeToolUseResultPairing(messages: AgentMessage[]): AgentMessage[] { - return repairToolUseResultPairing(messages).messages; +export function sanitizeToolUseResultPairing( + messages: AgentMessage[], + options?: ToolUseResultPairingOptions, +): AgentMessage[] { + return repairToolUseResultPairing(messages, options).messages; } export type ToolUseRepairReport = { @@ -339,7 +346,10 @@ export type ToolUseRepairReport = { moved: boolean; }; -export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRepairReport { +export function repairToolUseResultPairing( + messages: AgentMessage[], + options?: ToolUseResultPairingOptions, +): ToolUseRepairReport { // Anthropic (and Cloud Code Assist) reject transcripts where assistant tool calls are not // immediately followed by matching tool results. Session files can end up with results // displaced (e.g. after user turns) or duplicated. Repair by: @@ -390,18 +400,6 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep const assistant = msg as Extract; - // Skip tool call extraction for aborted or errored assistant messages. - // When stopReason is "error" or "aborted", the tool_use blocks may be incomplete - // (e.g., partialJson: true) and should not have synthetic tool_results created. - // Creating synthetic results for incomplete tool calls causes API 400 errors: - // "unexpected tool_use_id found in tool_result blocks" - // See: https://github.com/openclaw/openclaw/issues/4597 - const stopReason = (assistant as { stopReason?: string }).stopReason; - if (stopReason === "error" || stopReason === "aborted") { - out.push(msg); - continue; - } - const toolCalls = extractToolCallsFromAssistant(assistant); if (toolCalls.length === 0) { out.push(msg); @@ -459,6 +457,28 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep } } + // Aborted/errored assistant turns should never synthesize missing tool results, but + // the replay sanitizer can still legitimately retain real tool results for surviving + // tool calls in the same turn after malformed siblings are dropped. + const stopReason = (assistant as { stopReason?: string }).stopReason; + if (stopReason === "error" || stopReason === "aborted") { + out.push(msg); + if (options?.preserveErroredAssistantResults) { + for (const toolCall of toolCalls) { + const result = spanResultsById.get(toolCall.id); + if (!result) { + continue; + } + pushToolResult(result); + } + } + for (const rem of remainder) { + out.push(rem); + } + i = j - 1; + continue; + } + out.push(msg); if (spanResultsById.size > 0 && remainder.length > 0) { diff --git a/src/plugins/bundled-web-search-registry.ts b/src/bundled-web-search-registry.ts similarity index 56% rename from src/plugins/bundled-web-search-registry.ts rename to src/bundled-web-search-registry.ts index 15c04dd2935..c1f24639556 100644 --- a/src/plugins/bundled-web-search-registry.ts +++ b/src/bundled-web-search-registry.ts @@ -1,11 +1,11 @@ -import bravePlugin from "../../extensions/brave/index.js"; -import firecrawlPlugin from "../../extensions/firecrawl/index.js"; -import googlePlugin from "../../extensions/google/index.js"; -import moonshotPlugin from "../../extensions/moonshot/index.js"; -import perplexityPlugin from "../../extensions/perplexity/index.js"; -import tavilyPlugin from "../../extensions/tavily/index.js"; -import xaiPlugin from "../../extensions/xai/index.js"; -import type { OpenClawPluginApi } from "./types.js"; +import bravePlugin from "../extensions/brave/index.js"; +import firecrawlPlugin from "../extensions/firecrawl/index.js"; +import googlePlugin from "../extensions/google/index.js"; +import moonshotPlugin from "../extensions/moonshot/index.js"; +import perplexityPlugin from "../extensions/perplexity/index.js"; +import tavilyPlugin from "../extensions/tavily/index.js"; +import xaiPlugin from "../extensions/xai/index.js"; +import type { OpenClawPluginApi } from "./plugins/types.js"; type RegistrablePlugin = { id: string; diff --git a/src/plugins/bundled-web-search.ts b/src/plugins/bundled-web-search.ts index 5b709aa00ee..6eb87f431fa 100644 --- a/src/plugins/bundled-web-search.ts +++ b/src/plugins/bundled-web-search.ts @@ -1,4 +1,4 @@ -import { bundledWebSearchPluginRegistrations } from "./bundled-web-search-registry.js"; +import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js"; import { capturePluginRegistration } from "./captured-registration.js"; import type { PluginLoadOptions } from "./loader.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 98cefe7820c..0a419efebe1 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -34,7 +34,7 @@ import volcenginePlugin from "../../../extensions/volcengine/index.js"; import xaiPlugin from "../../../extensions/xai/index.js"; import xiaomiPlugin from "../../../extensions/xiaomi/index.js"; import zaiPlugin from "../../../extensions/zai/index.js"; -import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js"; +import { bundledWebSearchPluginRegistrations } from "../../bundled-web-search-registry.js"; import { createCapturedPluginRegistration } from "../captured-registration.js"; import { resolvePluginProviders } from "../providers.js"; import type { From 39a4fe576d4bdcf0898ad0907615f35fb2ebcc8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 22:06:16 +0000 Subject: [PATCH 7/9] test: normalize perf manifest paths --- scripts/test-runner-manifest.mjs | 19 +- scripts/test-update-memory-hotspots.mjs | 18 +- scripts/test-update-timings.mjs | 13 +- test/fixtures/test-timings.unit.json | 648 +++++++++++++++++------- 4 files changed, 513 insertions(+), 185 deletions(-) diff --git a/scripts/test-runner-manifest.mjs b/scripts/test-runner-manifest.mjs index ee5644f3328..ce34d28c59b 100644 --- a/scripts/test-runner-manifest.mjs +++ b/scripts/test-runner-manifest.mjs @@ -25,14 +25,25 @@ const readJson = (filePath, fallback) => { }; const normalizeRepoPath = (value) => value.split(path.sep).join("/"); +const repoRoot = path.resolve(process.cwd()); +const normalizeTrackedRepoPath = (value) => { + const normalizedValue = typeof value === "string" ? value : String(value ?? ""); + const repoRelative = path.isAbsolute(normalizedValue) + ? path.relative(repoRoot, path.resolve(normalizedValue)) + : normalizedValue; + if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") { + return normalizeRepoPath(normalizedValue); + } + return normalizeRepoPath(repoRelative); +}; const normalizeManifestEntries = (entries) => entries .map((entry) => typeof entry === "string" - ? { file: normalizeRepoPath(entry), reason: "" } + ? { file: normalizeTrackedRepoPath(entry), reason: "" } : { - file: normalizeRepoPath(String(entry?.file ?? "")), + file: normalizeTrackedRepoPath(String(entry?.file ?? "")), reason: typeof entry?.reason === "string" ? entry.reason : "", }, ) @@ -60,7 +71,7 @@ export function loadUnitTimingManifest() { const files = Object.fromEntries( Object.entries(raw.files ?? {}) .map(([file, value]) => { - const normalizedFile = normalizeRepoPath(file); + const normalizedFile = normalizeTrackedRepoPath(file); const durationMs = Number.isFinite(value?.durationMs) && value.durationMs >= 0 ? value.durationMs : null; const testCount = @@ -97,7 +108,7 @@ export function loadUnitMemoryHotspotManifest() { const files = Object.fromEntries( Object.entries(raw.files ?? {}) .map(([file, value]) => { - const normalizedFile = normalizeRepoPath(file); + const normalizedFile = normalizeTrackedRepoPath(file); const deltaKb = Number.isFinite(value?.deltaKb) && value.deltaKb > 0 ? Math.round(value.deltaKb) : null; const sources = Array.isArray(value?.sources) diff --git a/scripts/test-update-memory-hotspots.mjs b/scripts/test-update-memory-hotspots.mjs index 2abbf2b2d02..af4cb7c624c 100644 --- a/scripts/test-update-memory-hotspots.mjs +++ b/scripts/test-update-memory-hotspots.mjs @@ -57,10 +57,24 @@ function parseArgs(argv) { return args; } +const normalizeRepoPath = (value) => value.split(path.sep).join("/"); +const repoRoot = path.resolve(process.cwd()); +const normalizeTrackedRepoPath = (value) => { + const normalizedValue = typeof value === "string" ? value : String(value ?? ""); + const repoRelative = path.isAbsolute(normalizedValue) + ? path.relative(repoRoot, path.resolve(normalizedValue)) + : normalizedValue; + if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") { + return normalizeRepoPath(normalizedValue); + } + return normalizeRepoPath(repoRelative); +}; + function mergeHotspotEntry(aggregated, file, value) { if (!(Number.isFinite(value?.deltaKb) && value.deltaKb > 0)) { return; } + const normalizedFile = normalizeTrackedRepoPath(file); const normalizeSourceLabel = (source) => { const separator = source.lastIndexOf(":"); if (separator === -1) { @@ -75,9 +89,9 @@ function mergeHotspotEntry(aggregated, file, value) { .filter((source) => typeof source === "string" && source.length > 0) .map(normalizeSourceLabel) : []; - const previous = aggregated.get(file); + const previous = aggregated.get(normalizedFile); if (!previous) { - aggregated.set(file, { + aggregated.set(normalizedFile, { deltaKb: Math.round(value.deltaKb), sources: [...new Set(nextSources)], }); diff --git a/scripts/test-update-timings.mjs b/scripts/test-update-timings.mjs index 722d3539f7a..afc187bc4fe 100644 --- a/scripts/test-update-timings.mjs +++ b/scripts/test-update-timings.mjs @@ -50,6 +50,17 @@ function parseArgs(argv) { } const normalizeRepoPath = (value) => value.split(path.sep).join("/"); +const repoRoot = path.resolve(process.cwd()); +const normalizeTrackedRepoPath = (value) => { + const normalizedValue = typeof value === "string" ? value : String(value ?? ""); + const repoRelative = path.isAbsolute(normalizedValue) + ? path.relative(repoRoot, path.resolve(normalizedValue)) + : normalizedValue; + if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") { + return normalizeRepoPath(normalizedValue); + } + return normalizeRepoPath(repoRelative); +}; const opts = parseArgs(process.argv.slice(2)); const reportPath = @@ -74,7 +85,7 @@ const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); const files = Object.fromEntries( (report.testResults ?? []) .map((result) => { - const file = typeof result.name === "string" ? normalizeRepoPath(result.name) : ""; + const file = typeof result.name === "string" ? normalizeTrackedRepoPath(result.name) : ""; const start = typeof result.startTime === "number" ? result.startTime : 0; const end = typeof result.endTime === "number" ? result.endTime : 0; const testCount = Array.isArray(result.assertionResults) ? result.assertionResults.length : 0; diff --git a/test/fixtures/test-timings.unit.json b/test/fixtures/test-timings.unit.json index cdb2505d881..a334eec0c5a 100644 --- a/test/fixtures/test-timings.unit.json +++ b/test/fixtures/test-timings.unit.json @@ -1,227 +1,519 @@ { "config": "vitest.unit.config.ts", - "generatedAt": "2026-03-18T17:10:00.000Z", + "generatedAt": "2026-03-20T21:59:18.104Z", "defaultDurationMs": 250, "files": { - "src/security/audit.test.ts": { - "durationMs": 6200, - "testCount": 380 - }, "src/plugins/loader.test.ts": { - "durationMs": 6100, - "testCount": 260 + "durationMs": 9585.06884765625, + "testCount": 77 }, - "src/cli/update-cli.test.ts": { - "durationMs": 5400, - "testCount": 210 + "src/plugin-sdk/index.bundle.test.ts": { + "durationMs": 8950.05517578125, + "testCount": 1 }, - "src/agents/pi-embedded-runner.test.ts": { - "durationMs": 5200, - "testCount": 140 + "src/cron/isolated-agent/run.sandbox-config-preserved.test.ts": { + "durationMs": 8918.584228515625, + "testCount": 2 }, - "src/process/supervisor/supervisor.test.ts": { - "durationMs": 5000, - "testCount": 120 + "src/memory/manager.readonly-recovery.test.ts": { + "durationMs": 8524.26123046875, + "testCount": 4 }, - "src/agents/bash-tools.test.ts": { - "durationMs": 4700, - "testCount": 150 + "src/context-engine/context-engine.test.ts": { + "durationMs": 8457.03515625, + "testCount": 27 }, - "src/cli/program.smoke.test.ts": { - "durationMs": 4500, - "testCount": 95 + "src/channels/plugins/setup-wizard-helpers.test.ts": { + "durationMs": 8405.74267578125, + "testCount": 83 }, - "src/hooks/install.test.ts": { - "durationMs": 4300, - "testCount": 95 + "test/extension-plugin-sdk-boundary.test.ts": { + "durationMs": 7965.701171875, + "testCount": 7 }, - "src/agents/skills.test.ts": { - "durationMs": 4200, - "testCount": 135 + "src/config/doc-baseline.integration.test.ts": { + "durationMs": 6192.561767578125, + "testCount": 7 }, - "src/config/schema.test.ts": { - "durationMs": 4000, - "testCount": 110 + "src/daemon/schtasks.stop.test.ts": { + "durationMs": 5804.337158203125, + "testCount": 4 }, - "src/media/store.test.ts": { - "durationMs": 3900, - "testCount": 120 + "src/media/fetch.telegram-network.test.ts": { + "durationMs": 5003.539306640625, + "testCount": 5 }, - "src/commands/agent.test.ts": { - "durationMs": 3700, - "testCount": 110 + "src/infra/restart.test.ts": { + "durationMs": 4300.315673828125, + "testCount": 5 }, - "extensions/telegram/src/bot.create-telegram-bot.test.ts": { - "durationMs": 3600, - "testCount": 80 + "src/channels/plugins/contracts/registry.contract.test.ts": { + "durationMs": 3514.9697265625, + "testCount": 10 }, - "extensions/telegram/src/bot.test.ts": { - "durationMs": 3400, - "testCount": 95 + "src/media-understanding/providers/image.test.ts": { + "durationMs": 3185.248779296875, + "testCount": 4 }, - "src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts": { - "durationMs": 3300, - "testCount": 85 + "test/web-search-provider-boundary.test.ts": { + "durationMs": 2782.843505859375, + "testCount": 4 }, - "src/infra/archive.test.ts": { - "durationMs": 3200, - "testCount": 75 + "src/infra/outbound/message.test.ts": { + "durationMs": 2701.229736328125, + "testCount": 3 }, - "src/auto-reply/reply.block-streaming.test.ts": { - "durationMs": 3100, - "testCount": 60 + "src/tts/edge-tts-validation.test.ts": { + "durationMs": 2662.32421875, + "testCount": 2 }, - "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts": { - "durationMs": 3000, - "testCount": 55 + "src/media-understanding/runner.vision-skip.test.ts": { + "durationMs": 2446.17724609375, + "testCount": 1 }, - "src/agents/skills.buildworkspaceskillsnapshot.test.ts": { - "durationMs": 2900, - "testCount": 70 + "src/infra/outbound/agent-delivery.test.ts": { + "durationMs": 2414.775390625, + "testCount": 6 }, - "src/docker-setup.test.ts": { - "durationMs": 2800, - "testCount": 65 + "src/memory/manager.read-file.test.ts": { + "durationMs": 2413.658203125, + "testCount": 4 }, - "src/agents/skills-install.download.test.ts": { - "durationMs": 2700, - "testCount": 60 + "src/memory/manager.sync-errors-do-not-crash.test.ts": { + "durationMs": 2389.0439453125, + "testCount": 1 }, - "src/config/schema.tags.test.ts": { - "durationMs": 2600, - "testCount": 70 + "src/acp/runtime/session-meta.test.ts": { + "durationMs": 2388.85302734375, + "testCount": 1 }, - "src/cli/daemon-cli.coverage.test.ts": { - "durationMs": 2500, - "testCount": 50 + "src/infra/provider-usage.auth.plugin.test.ts": { + "durationMs": 2376.7861328125, + "testCount": 1 }, - "extensions/slack/src/monitor/slash.test.ts": { - "durationMs": 2400, - "testCount": 55 + "src/infra/provider-usage.load.plugin.test.ts": { + "durationMs": 2347.157470703125, + "testCount": 1 }, - "test/git-hooks-pre-commit.test.ts": { - "durationMs": 2300, - "testCount": 20 + "src/index.test.ts": { + "durationMs": 2344.759521484375, + "testCount": 2 }, - "src/commands/doctor.warns-state-directory-is-missing.test.ts": { - "durationMs": 2200, - "testCount": 35 - }, - "src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts": { - "durationMs": 2100, - "testCount": 30 - }, - "src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts": { - "durationMs": 2000, - "testCount": 28 - }, - "src/browser/server.agent-contract-snapshot-endpoints.test.ts": { - "durationMs": 1900, - "testCount": 45 - }, - "src/browser/server.agent-contract-form-layout-act-commands.test.ts": { - "durationMs": 1800, - "testCount": 40 - }, - "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts": { - "durationMs": 1700, - "testCount": 25 - }, - "src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts": { - "durationMs": 1600, - "testCount": 22 - }, - "src/plugins/tools.optional.test.ts": { - "durationMs": 1590, - "testCount": 18 - }, - "src/security/fix.test.ts": { - "durationMs": 1580, - "testCount": 24 - }, - "src/utils.test.ts": { - "durationMs": 1570, + "src/plugins/install.test.ts": { + "durationMs": 1894.49658203125, "testCount": 34 }, - "src/auto-reply/tool-meta.test.ts": { - "durationMs": 1560, - "testCount": 26 + "src/config/plugin-auto-enable.test.ts": { + "durationMs": 1378.89013671875, + "testCount": 25 }, - "src/auto-reply/envelope.test.ts": { - "durationMs": 1550, - "testCount": 20 + "src/plugin-sdk/channel-import-guardrails.test.ts": { + "durationMs": 1158.282470703125, + "testCount": 9 }, - "src/commands/auth-choice.test.ts": { - "durationMs": 1540, - "testCount": 18 + "src/hooks/bundled/session-memory/handler.test.ts": { + "durationMs": 1136.251953125, + "testCount": 17 }, - "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts": { - "durationMs": 1530, - "testCount": 14 - }, - "src/media/store.header-ext.test.ts": { - "durationMs": 1520, - "testCount": 16 - }, - "extensions/whatsapp/src/media.test.ts": { - "durationMs": 1510, - "testCount": 16 - }, - "extensions/whatsapp/src/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts": { - "durationMs": 1500, - "testCount": 10 - }, - "src/browser/server.covers-additional-endpoint-branches.test.ts": { - "durationMs": 1490, - "testCount": 18 - }, - "src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts": { - "durationMs": 1480, - "testCount": 12 - }, - "src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts": { - "durationMs": 1470, - "testCount": 10 - }, - "src/browser/server.auth-token-gates-http.test.ts": { - "durationMs": 1460, + "src/hooks/install.test.ts": { + "durationMs": 978.206298828125, "testCount": 15 }, - "extensions/acpx/src/runtime.test.ts": { - "durationMs": 1450, - "testCount": 12 + "test/plugin-extension-import-boundary.test.ts": { + "durationMs": 975.744873046875, + "testCount": 5 }, - "test/scripts/ios-team-id.test.ts": { - "durationMs": 1440, - "testCount": 12 + "test/architecture-smells.test.ts": { + "durationMs": 741.625732421875, + "testCount": 2 }, - "src/agents/bash-tools.exec.background-abort.test.ts": { - "durationMs": 1430, - "testCount": 10 - }, - "src/agents/subagent-announce.format.test.ts": { - "durationMs": 1420, - "testCount": 12 - }, - "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts": { - "durationMs": 1410, + "src/hooks/loader.test.ts": { + "durationMs": 735.1630859375, "testCount": 14 }, - "src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts": { - "durationMs": 1400, - "testCount": 10 + "src/infra/fs-safe.test.ts": { + "durationMs": 729.53564453125, + "testCount": 27 }, - "src/auto-reply/reply.triggers.group-intro-prompts.test.ts": { - "durationMs": 1390, + "test/scripts/committer.test.ts": { + "durationMs": 626.26806640625, + "testCount": 3 + }, + "src/cron/isolated-agent.model-formatting.test.ts": { + "durationMs": 593.440185546875, + "testCount": 22 + }, + "src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts": { + "durationMs": 571.946533203125, + "testCount": 18 + }, + "src/config/config.plugin-validation.test.ts": { + "durationMs": 565.86474609375, + "testCount": 14 + }, + "src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts": { + "durationMs": 530.2373046875, + "testCount": 15 + }, + "src/infra/provider-usage.test.ts": { + "durationMs": 524.179443359375, + "testCount": 11 + }, + "src/cron/service.issue-regressions.test.ts": { + "durationMs": 457.494873046875, + "testCount": 38 + }, + "src/infra/provider-usage.auth.normalizes-keys.test.ts": { + "durationMs": 450.132568359375, + "testCount": 19 + }, + "src/infra/fs-pinned-write-helper.test.ts": { + "durationMs": 338.172119140625, + "testCount": 3 + }, + "src/infra/archive.test.ts": { + "durationMs": 329.4638671875, + "testCount": 15 + }, + "src/memory/manager.get-concurrency.test.ts": { + "durationMs": 276.911376953125, + "testCount": 2 + }, + "src/cli/program/preaction.test.ts": { + "durationMs": 266.180908203125, + "testCount": 7 + }, + "src/memory/index.test.ts": { + "durationMs": 263.556884765625, + "testCount": 21 + }, + "src/security/temp-path-guard.test.ts": { + "durationMs": 262.98779296875, + "testCount": 3 + }, + "src/security/audit.test.ts": { + "durationMs": 258.43408203125, + "testCount": 65 + }, + "src/memory/embeddings.test.ts": { + "durationMs": 243.285888671875, + "testCount": 19 + }, + "src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts": { + "durationMs": 239.01611328125, + "testCount": 6 + }, + "src/memory/qmd-manager.test.ts": { + "durationMs": 238.613525390625, + "testCount": 57 + }, + "src/infra/archive-staging.test.ts": { + "durationMs": 228.458740234375, + "testCount": 7 + }, + "src/secrets/audit.test.ts": { + "durationMs": 226.931396484375, + "testCount": 18 + }, + "test/scripts/test-extension.test.ts": { + "durationMs": 224.01171875, + "testCount": 8 + }, + "src/infra/git-commit.test.ts": { + "durationMs": 214.883056640625, + "testCount": 13 + }, + "src/tui/gateway-chat.test.ts": { + "durationMs": 210.46240234375, + "testCount": 14 + }, + "src/secrets/runtime.integration.test.ts": { + "durationMs": 210.15087890625, + "testCount": 5 + }, + "src/secrets/apply.test.ts": { + "durationMs": 208.744140625, + "testCount": 15 + }, + "src/entry.version-fast-path.test.ts": { + "durationMs": 192.80029296875, + "testCount": 2 + }, + "src/acp/control-plane/manager.test.ts": { + "durationMs": 183.112548828125, + "testCount": 33 + }, + "src/install-sh-version.test.ts": { + "durationMs": 180.623291015625, + "testCount": 3 + }, + "src/infra/host-env-security.test.ts": { + "durationMs": 180.501220703125, + "testCount": 18 + }, + "src/plugins/loader.git-path-regression.test.ts": { + "durationMs": 178.922119140625, + "testCount": 1 + }, + "src/hooks/plugin-hooks.test.ts": { + "durationMs": 177.90771484375, + "testCount": 4 + }, + "src/cli/daemon-cli/install.integration.test.ts": { + "durationMs": 174.057861328125, + "testCount": 2 + }, + "src/plugins/bundle-mcp.test.ts": { + "durationMs": 169.723876953125, + "testCount": 3 + }, + "src/acp/server.startup.test.ts": { + "durationMs": 161.5439453125, + "testCount": 4 + }, + "src/media-understanding/apply.test.ts": { + "durationMs": 150.961181640625, + "testCount": 32 + }, + "src/cron/isolated-agent.direct-delivery-core-channels.test.ts": { + "durationMs": 148.2373046875, + "testCount": 4 + }, + "src/daemon/schtasks.startup-fallback.test.ts": { + "durationMs": 144.08154296875, + "testCount": 6 + }, + "src/cron/isolated-agent.subagent-model.test.ts": { + "durationMs": 142.85693359375, + "testCount": 4 + }, + "src/channels/plugins/plugins-core.test.ts": { + "durationMs": 142.499755859375, + "testCount": 39 + }, + "src/infra/heartbeat-runner.returns-default-unset.test.ts": { + "durationMs": 135.578369140625, + "testCount": 25 + }, + "src/plugins/manifest-registry.test.ts": { + "durationMs": 133.34912109375, + "testCount": 21 + }, + "src/plugin-sdk/subpaths.test.ts": { + "durationMs": 132.722900390625, + "testCount": 45 + }, + "src/node-host/invoke-system-run-plan.test.ts": { + "durationMs": 128.076171875, + "testCount": 41 + }, + "test/scripts/ios-team-id.test.ts": { + "durationMs": 124.882568359375, + "testCount": 3 + }, + "src/config/schema.hints.test.ts": { + "durationMs": 124.705810546875, + "testCount": 7 + }, + "src/infra/system-presence.version.test.ts": { + "durationMs": 124.248046875, + "testCount": 5 + }, + "src/config/config.nix-integration-u3-u5-u9.test.ts": { + "durationMs": 123.738037109375, + "testCount": 19 + }, + "src/infra/run-node.test.ts": { + "durationMs": 122.07763671875, "testCount": 12 }, - "src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts": { - "durationMs": 1380, + "src/secrets/resolve.test.ts": { + "durationMs": 121.808837890625, + "testCount": 17 + }, + "ui/src/ui/views/chat.test.ts": { + "durationMs": 121.7890625, + "testCount": 26 + }, + "src/media/store.outside-workspace.test.ts": { + "durationMs": 117.4501953125, + "testCount": 1 + }, + "src/plugins/marketplace.test.ts": { + "durationMs": 117.027587890625, + "testCount": 3 + }, + "src/config/sessions/sessions.test.ts": { + "durationMs": 116.381591796875, + "testCount": 23 + }, + "src/memory/manager.batch.test.ts": { + "durationMs": 113.201416015625, + "testCount": 3 + }, + "src/cron/isolated-agent.lane.test.ts": { + "durationMs": 109.29296875, + "testCount": 3 + }, + "src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts": { + "durationMs": 107.308349609375, + "testCount": 12 + }, + "src/cron/isolated-agent/run.owner-auth.test.ts": { + "durationMs": 106.2158203125, + "testCount": 1 + }, + "src/media/read-response-with-limit.test.ts": { + "durationMs": 103.88232421875, + "testCount": 5 + }, + "src/cli/config-cli.integration.test.ts": { + "durationMs": 101.070068359375, + "testCount": 4 + }, + "src/config/io.write-config.test.ts": { + "durationMs": 97.5205078125, + "testCount": 16 + }, + "src/infra/gateway-lock.test.ts": { + "durationMs": 97.258056640625, + "testCount": 9 + }, + "src/infra/outbound/outbound.test.ts": { + "durationMs": 97.128662109375, + "testCount": 65 + }, + "src/security/windows-acl.test.ts": { + "durationMs": 95.044921875, + "testCount": 48 + }, + "src/cron/isolated-agent.direct-delivery-forum-topics.test.ts": { + "durationMs": 93.414306640625, + "testCount": 2 + }, + "src/media-understanding/apply.echo-transcript.test.ts": { + "durationMs": 90.539306640625, "testCount": 10 }, - "extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts": { - "durationMs": 1370, + "test/git-hooks-pre-commit.test.ts": { + "durationMs": 89.74560546875, + "testCount": 1 + }, + "src/plugins/contracts/auth-choice.contract.test.ts": { + "durationMs": 87.48828125, + "testCount": 3 + }, + "src/infra/device-pairing.test.ts": { + "durationMs": 87.477294921875, + "testCount": 19 + }, + "src/pairing/pairing-store.test.ts": { + "durationMs": 86.443115234375, + "testCount": 17 + }, + "src/pairing/setup-code.test.ts": { + "durationMs": 86.40185546875, + "testCount": 15 + }, + "src/media-understanding/runner.skip-tiny-audio.test.ts": { + "durationMs": 85.822265625, + "testCount": 3 + }, + "src/hooks/hooks-install.test.ts": { + "durationMs": 85.01025390625, + "testCount": 1 + }, + "src/media/input-files.fetch-guard.test.ts": { + "durationMs": 83.118408203125, "testCount": 10 + }, + "src/media-understanding/runner.proxy.test.ts": { + "durationMs": 82.6806640625, + "testCount": 3 + }, + "src/plugin-sdk/channel-lifecycle.test.ts": { + "durationMs": 82.321533203125, + "testCount": 6 + }, + "src/media-understanding/runner.deepgram.test.ts": { + "durationMs": 82.171875, + "testCount": 1 + }, + "src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts": { + "durationMs": 78.29052734375, + "testCount": 15 + }, + "src/media-understanding/runner.auto-audio.test.ts": { + "durationMs": 77.9013671875, + "testCount": 4 + }, + "src/config/sessions.test.ts": { + "durationMs": 76.888916015625, + "testCount": 37 + }, + "src/process/command-queue.test.ts": { + "durationMs": 75.699951171875, + "testCount": 17 + }, + "src/node-host/invoke-system-run.test.ts": { + "durationMs": 75.633544921875, + "testCount": 37 + }, + "src/cli/program.smoke.test.ts": { + "durationMs": 74.6591796875, + "testCount": 4 + }, + "src/plugins/stage-bundled-plugin-runtime.test.ts": { + "durationMs": 74.08447265625, + "testCount": 7 + }, + "src/infra/matrix-legacy-crypto.test.ts": { + "durationMs": 72.4951171875, + "testCount": 8 + }, + "src/plugins/discovery.test.ts": { + "durationMs": 71.763671875, + "testCount": 24 + }, + "src/plugins/status.test.ts": { + "durationMs": 71.670654296875, + "testCount": 9 + }, + "src/wizard/setup.gateway-config.test.ts": { + "durationMs": 71.062255859375, + "testCount": 7 + }, + "src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts": { + "durationMs": 69.632568359375, + "testCount": 30 + }, + "src/config/sessions/targets.test.ts": { + "durationMs": 69.172607421875, + "testCount": 13 + }, + "src/media/store.test.ts": { + "durationMs": 67.70458984375, + "testCount": 24 + }, + "src/canvas-host/server.test.ts": { + "durationMs": 67.617431640625, + "testCount": 6 + }, + "src/tts/tts.test.ts": { + "durationMs": 67.5400390625, + "testCount": 27 + }, + "src/infra/heartbeat-runner.ghost-reminder.test.ts": { + "durationMs": 66.83935546875, + "testCount": 6 + }, + "src/cli/pairing-cli.test.ts": { + "durationMs": 65.74462890625, + "testCount": 12 + }, + "src/media-understanding/runtime.test.ts": { + "durationMs": 65.732177734375, + "testCount": 2 } } } From fac64c2392db760bbc8b3ff4846ccf076a4bef58 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 22:33:49 +0000 Subject: [PATCH 8/9] test: widen unit timing snapshot coverage --- scripts/test-update-timings.mjs | 2 +- test/fixtures/test-timings.unit.json | 514 ++++++++++++++++++++++++++- 2 files changed, 514 insertions(+), 2 deletions(-) diff --git a/scripts/test-update-timings.mjs b/scripts/test-update-timings.mjs index afc187bc4fe..e450ff9cd31 100644 --- a/scripts/test-update-timings.mjs +++ b/scripts/test-update-timings.mjs @@ -9,7 +9,7 @@ function parseArgs(argv) { config: "vitest.unit.config.ts", out: unitTimingManifestPath, reportPath: "", - limit: 128, + limit: 256, defaultDurationMs: 250, }; for (let i = 0; i < argv.length; i += 1) { diff --git a/test/fixtures/test-timings.unit.json b/test/fixtures/test-timings.unit.json index a334eec0c5a..bea7e0d1178 100644 --- a/test/fixtures/test-timings.unit.json +++ b/test/fixtures/test-timings.unit.json @@ -1,6 +1,6 @@ { "config": "vitest.unit.config.ts", - "generatedAt": "2026-03-20T21:59:18.104Z", + "generatedAt": "2026-03-20T22:27:39.886Z", "defaultDurationMs": 250, "files": { "src/plugins/loader.test.ts": { @@ -514,6 +514,518 @@ "src/media-understanding/runtime.test.ts": { "durationMs": 65.732177734375, "testCount": 2 + }, + "src/infra/update-runner.test.ts": { + "durationMs": 64.987060546875, + "testCount": 20 + }, + "src/cron/service.failure-alert.test.ts": { + "durationMs": 63.978271484375, + "testCount": 4 + }, + "src/secrets/runtime.test.ts": { + "durationMs": 63.96337890625, + "testCount": 55 + }, + "src/infra/outbound/delivery-queue.test.ts": { + "durationMs": 63.504150390625, + "testCount": 36 + }, + "src/config/config.web-search-provider.test.ts": { + "durationMs": 62.205322265625, + "testCount": 23 + }, + "src/memory/manager.embedding-batches.test.ts": { + "durationMs": 61.173583984375, + "testCount": 5 + }, + "src/cron/service.persists-delivered-status.test.ts": { + "durationMs": 60.770263671875, + "testCount": 6 + }, + "src/cron/isolated-agent.auth-profile-propagation.test.ts": { + "durationMs": 60.474365234375, + "testCount": 1 + }, + "src/infra/jsonl-socket.test.ts": { + "durationMs": 59.739013671875, + "testCount": 2 + }, + "src/infra/session-maintenance-warning.test.ts": { + "durationMs": 58.515869140625, + "testCount": 5 + }, + "src/cron/service.restart-catchup.test.ts": { + "durationMs": 57.26123046875, + "testCount": 8 + }, + "src/config/schema.test.ts": { + "durationMs": 57.260986328125, + "testCount": 22 + }, + "src/plugins/bundled-web-search.test.ts": { + "durationMs": 56.5693359375, + "testCount": 2 + }, + "src/plugin-sdk/keyed-async-queue.test.ts": { + "durationMs": 56.42333984375, + "testCount": 4 + }, + "src/plugins/contracts/registry.contract.test.ts": { + "durationMs": 56.16650390625, + "testCount": 19 + }, + "src/plugins/tools.optional.test.ts": { + "durationMs": 55.7021484375, + "testCount": 8 + }, + "src/plugins/conversation-binding.test.ts": { + "durationMs": 55.24609375, + "testCount": 15 + }, + "src/plugins/copy-bundled-plugin-metadata.test.ts": { + "durationMs": 54.4267578125, + "testCount": 8 + }, + "src/infra/install-package-dir.test.ts": { + "durationMs": 54.185546875, + "testCount": 5 + }, + "src/infra/boundary-path.test.ts": { + "durationMs": 53.643310546875, + "testCount": 5 + }, + "src/infra/heartbeat-runner.model-override.test.ts": { + "durationMs": 52.62109375, + "testCount": 8 + }, + "src/infra/outbound/outbound-send-service.test.ts": { + "durationMs": 52.319091796875, + "testCount": 9 + }, + "src/media-understanding/providers/index.test.ts": { + "durationMs": 52.12060546875, + "testCount": 3 + }, + "src/security/fix.test.ts": { + "durationMs": 51.84716796875, + "testCount": 5 + }, + "src/channels/plugins/acp-bindings.test.ts": { + "durationMs": 51.03369140625, + "testCount": 6 + }, + "src/config/sessions/store.pruning.integration.test.ts": { + "durationMs": 50.060546875, + "testCount": 10 + }, + "src/config/io.runtime-snapshot-write.test.ts": { + "durationMs": 49.54736328125, + "testCount": 6 + }, + "src/cli/route.test.ts": { + "durationMs": 49.52734375, + "testCount": 3 + }, + "src/plugins/web-search-providers.test.ts": { + "durationMs": 49.430908203125, + "testCount": 7 + }, + "src/infra/matrix-legacy-state.test.ts": { + "durationMs": 49.007080078125, + "testCount": 6 + }, + "src/config/config.pruning-defaults.test.ts": { + "durationMs": 47.780029296875, + "testCount": 7 + }, + "src/memory/embeddings-voyage.test.ts": { + "durationMs": 47.3974609375, + "testCount": 4 + }, + "src/infra/ports.test.ts": { + "durationMs": 46.749267578125, + "testCount": 5 + }, + "src/routing/resolve-route.test.ts": { + "durationMs": 46.55078125, + "testCount": 41 + }, + "src/plugins/providers.test.ts": { + "durationMs": 46.517333984375, + "testCount": 7 + }, + "src/cli/plugin-registry.test.ts": { + "durationMs": 45.814697265625, + "testCount": 2 + }, + "src/infra/matrix-plugin-helper.test.ts": { + "durationMs": 44.94140625, + "testCount": 4 + }, + "src/cron/service.store.migration.test.ts": { + "durationMs": 44.7314453125, + "testCount": 7 + }, + "src/logging/log-file-size-cap.test.ts": { + "durationMs": 44.7001953125, + "testCount": 3 + }, + "src/process/supervisor/supervisor.pty-command.test.ts": { + "durationMs": 44.529052734375, + "testCount": 2 + }, + "src/plugins/hook-runner-global.test.ts": { + "durationMs": 44.1142578125, + "testCount": 2 + }, + "src/cron/service.every-jobs-fire.test.ts": { + "durationMs": 43.72607421875, + "testCount": 3 + }, + "src/channels/plugins/whatsapp-heartbeat.test.ts": { + "durationMs": 43.144775390625, + "testCount": 8 + }, + "src/infra/update-startup.test.ts": { + "durationMs": 42.65380859375, + "testCount": 10 + }, + "src/infra/matrix-migration-snapshot.test.ts": { + "durationMs": 42.47119140625, + "testCount": 7 + }, + "src/config/schema.help.quality.test.ts": { + "durationMs": 42.340087890625, + "testCount": 20 + }, + "src/memory/internal.test.ts": { + "durationMs": 42.137939453125, + "testCount": 18 + }, + "src/cron/service.store-migration.test.ts": { + "durationMs": 42.07421875, + "testCount": 5 + }, + "src/cron/run-log.test.ts": { + "durationMs": 42.0673828125, + "testCount": 11 + }, + "src/config/env-preserve-io.test.ts": { + "durationMs": 41.8037109375, + "testCount": 4 + }, + "src/plugins/web-search-providers.runtime.test.ts": { + "durationMs": 41.41015625, + "testCount": 2 + }, + "src/cron/service.issue-16156-list-skips-cron.test.ts": { + "durationMs": 39.339599609375, + "testCount": 3 + }, + "src/cron/service.runs-one-shot-main-job-disables-it.test.ts": { + "durationMs": 39.2939453125, + "testCount": 11 + }, + "src/memory/batch-gemini.test.ts": { + "durationMs": 38.654052734375, + "testCount": 1 + }, + "src/media/fetch.test.ts": { + "durationMs": 38.048583984375, + "testCount": 6 + }, + "src/process/exec.windows.test.ts": { + "durationMs": 37.954833984375, + "testCount": 2 + }, + "src/acp/persistent-bindings.lifecycle.test.ts": { + "durationMs": 37.9296875, + "testCount": 1 + }, + "src/config/config.identity-defaults.test.ts": { + "durationMs": 37.58984375, + "testCount": 7 + }, + "src/cron/isolated-agent/run.skill-filter.test.ts": { + "durationMs": 37.4345703125, + "testCount": 13 + }, + "src/process/exec.no-output-timer.test.ts": { + "durationMs": 37.43212890625, + "testCount": 1 + }, + "src/infra/net/proxy-fetch.test.ts": { + "durationMs": 37.217041015625, + "testCount": 10 + }, + "src/config/mcp-config.test.ts": { + "durationMs": 36.172607421875, + "testCount": 2 + }, + "src/infra/device-bootstrap.test.ts": { + "durationMs": 36.03564453125, + "testCount": 7 + }, + "src/memory/manager.atomic-reindex.test.ts": { + "durationMs": 35.837890625, + "testCount": 1 + }, + "src/infra/state-migrations.test.ts": { + "durationMs": 35.7705078125, + "testCount": 2 + }, + "src/infra/json-files.test.ts": { + "durationMs": 35.27685546875, + "testCount": 5 + }, + "src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts": { + "durationMs": 35.118408203125, + "testCount": 1 + }, + "src/infra/outbound/targets.channel-resolution.test.ts": { + "durationMs": 34.466796875, + "testCount": 2 + }, + "src/infra/provider-usage.fetch.claude.test.ts": { + "durationMs": 34.194091796875, + "testCount": 13 + }, + "src/cli/program/config-guard.test.ts": { + "durationMs": 34.148193359375, + "testCount": 8 + }, + "src/media/server.test.ts": { + "durationMs": 34.0576171875, + "testCount": 9 + }, + "src/cron/service.read-ops-nonblocking.test.ts": { + "durationMs": 33.436279296875, + "testCount": 3 + }, + "src/cli/daemon-cli/restart-health.test.ts": { + "durationMs": 33.208740234375, + "testCount": 10 + }, + "src/infra/exec-approvals-store.test.ts": { + "durationMs": 33.11865234375, + "testCount": 8 + }, + "src/infra/transport-ready.test.ts": { + "durationMs": 32.873046875, + "testCount": 6 + }, + "src/infra/matrix-migration-config.test.ts": { + "durationMs": 32.8076171875, + "testCount": 7 + }, + "src/config/plugins-runtime-boundary.test.ts": { + "durationMs": 32.71142578125, + "testCount": 3 + }, + "src/config/config.backup-rotation.test.ts": { + "durationMs": 32.4921875, + "testCount": 4 + }, + "src/plugins/schema-validator.test.ts": { + "durationMs": 32.45654296875, + "testCount": 7 + }, + "src/infra/outbound/channel-resolution.test.ts": { + "durationMs": 32.39794921875, + "testCount": 6 + }, + "src/memory/manager.async-search.test.ts": { + "durationMs": 32.262451171875, + "testCount": 2 + }, + "src/cli/config-cli.test.ts": { + "durationMs": 32.14404296875, + "testCount": 48 + }, + "src/cron/isolated-agent/run.cron-model-override.test.ts": { + "durationMs": 31.62060546875, + "testCount": 6 + }, + "src/cron/service.session-reaper-in-finally.test.ts": { + "durationMs": 31.336181640625, + "testCount": 3 + }, + "src/config/config.talk-validation.test.ts": { + "durationMs": 31.318359375, + "testCount": 5 + }, + "src/cron/isolated-agent/run.message-tool-policy.test.ts": { + "durationMs": 30.740966796875, + "testCount": 3 + }, + "src/cron/isolated-agent/run.payload-fallbacks.test.ts": { + "durationMs": 30.61376953125, + "testCount": 3 + }, + "src/security/skill-scanner.test.ts": { + "durationMs": 30.46142578125, + "testCount": 27 + }, + "src/infra/push-apns.store.test.ts": { + "durationMs": 30.40869140625, + "testCount": 7 + }, + "src/infra/provider-usage.fetch.shared.test.ts": { + "durationMs": 30.35546875, + "testCount": 9 + }, + "src/config/config.compaction-settings.test.ts": { + "durationMs": 30.129638671875, + "testCount": 5 + }, + "src/infra/heartbeat-runner.transcript-prune.test.ts": { + "durationMs": 30.003173828125, + "testCount": 2 + }, + "src/infra/install-source-utils.test.ts": { + "durationMs": 29.82958984375, + "testCount": 16 + }, + "src/infra/outbound/message-action-runner.media.test.ts": { + "durationMs": 29.814697265625, + "testCount": 7 + }, + "src/infra/infra-runtime.test.ts": { + "durationMs": 29.746337890625, + "testCount": 11 + }, + "src/config/io.compat.test.ts": { + "durationMs": 29.640625, + "testCount": 7 + }, + "src/cron/isolated-agent/run.interim-retry.test.ts": { + "durationMs": 29.623779296875, + "testCount": 3 + }, + "src/infra/provider-usage.fetch.zai.test.ts": { + "durationMs": 29.40576171875, + "testCount": 5 + }, + "src/config/sessions.cache.test.ts": { + "durationMs": 28.903076171875, + "testCount": 9 + }, + "src/config/config-misc.test.ts": { + "durationMs": 28.822509765625, + "testCount": 38 + }, + "src/cron/isolated-agent/run.fast-mode.test.ts": { + "durationMs": 28.714111328125, + "testCount": 3 + }, + "src/cli/daemon-cli.coverage.test.ts": { + "durationMs": 28.397705078125, + "testCount": 5 + }, + "src/infra/outbound/deliver.test.ts": { + "durationMs": 28.26123046875, + "testCount": 43 + }, + "src/infra/session-cost-usage.test.ts": { + "durationMs": 28.23828125, + "testCount": 9 + }, + "src/infra/provider-usage.fetch.minimax.test.ts": { + "durationMs": 28.202880859375, + "testCount": 10 + }, + "src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts": { + "durationMs": 28.074951171875, + "testCount": 28 + }, + "src/cron/session-reaper.test.ts": { + "durationMs": 27.93896484375, + "testCount": 16 + }, + "src/infra/fetch.test.ts": { + "durationMs": 27.3388671875, + "testCount": 16 + }, + "src/infra/provider-usage.fetch.codex.test.ts": { + "durationMs": 27.201904296875, + "testCount": 8 + }, + "src/daemon/service-audit.test.ts": { + "durationMs": 27.169921875, + "testCount": 16 + }, + "src/plugin-sdk/persistent-dedupe.test.ts": { + "durationMs": 26.39892578125, + "testCount": 6 + }, + "src/plugin-sdk/fetch-auth.test.ts": { + "durationMs": 26.328857421875, + "testCount": 5 + }, + "src/cron/store.test.ts": { + "durationMs": 26.302978515625, + "testCount": 11 + }, + "src/infra/provider-usage.fetch.gemini.test.ts": { + "durationMs": 26.23388671875, + "testCount": 4 + }, + "src/plugins/uninstall.test.ts": { + "durationMs": 26.126220703125, + "testCount": 23 + }, + "src/memory/post-json.test.ts": { + "durationMs": 25.935302734375, + "testCount": 2 + }, + "src/config/logging.test.ts": { + "durationMs": 25.74267578125, + "testCount": 2 + }, + "src/infra/outbound/message-action-runner.plugin-dispatch.test.ts": { + "durationMs": 25.54638671875, + "testCount": 10 + }, + "src/infra/secret-file.test.ts": { + "durationMs": 25.1318359375, + "testCount": 11 + }, + "src/cli/mcp-cli.test.ts": { + "durationMs": 25.1083984375, + "testCount": 2 + }, + "src/plugins/bundle-manifest.test.ts": { + "durationMs": 25.0634765625, + "testCount": 8 + }, + "src/cli/prompt.test.ts": { + "durationMs": 24.9404296875, + "testCount": 2 + }, + "src/node-host/invoke-browser.test.ts": { + "durationMs": 24.701171875, + "testCount": 4 + }, + "src/memory/manager.mistral-provider.test.ts": { + "durationMs": 24.597412109375, + "testCount": 3 + }, + "src/cli/memory-cli.test.ts": { + "durationMs": 24.389404296875, + "testCount": 24 + }, + "src/infra/system-presence.test.ts": { + "durationMs": 24.2265625, + "testCount": 5 + }, + "src/channels/plugins/contracts/session-binding.contract.test.ts": { + "durationMs": 24.113037109375, + "testCount": 16 + }, + "src/cron/service.delivery-plan.test.ts": { + "durationMs": 23.473876953125, + "testCount": 3 } } } From 42ca447189a2d4f65f2b2960bd5458b714d2fea2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 15:29:51 -0700 Subject: [PATCH 9/9] test(openrouter): add live plugin coverage --- extensions/openrouter/index.test.ts | 101 ++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 extensions/openrouter/index.test.ts diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts new file mode 100644 index 00000000000..fa4cbda6cd2 --- /dev/null +++ b/extensions/openrouter/index.test.ts @@ -0,0 +1,101 @@ +import OpenAI from "openai"; +import { describe, expect, it } from "vitest"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import plugin from "./index.js"; + +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? ""; +const LIVE_MODEL_ID = + process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() || "openai/gpt-5.4-nano"; +const liveEnabled = OPENROUTER_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1"; +const describeLive = liveEnabled ? describe : describe.skip; + +function registerOpenRouterPlugin() { + const providers: unknown[] = []; + const speechProviders: unknown[] = []; + const mediaProviders: unknown[] = []; + const imageProviders: unknown[] = []; + + plugin.register( + createTestPluginApi({ + id: "openrouter", + name: "OpenRouter Provider", + source: "test", + config: {}, + runtime: {} as never, + registerProvider: (provider) => { + providers.push(provider); + }, + registerSpeechProvider: (provider) => { + speechProviders.push(provider); + }, + registerMediaUnderstandingProvider: (provider) => { + mediaProviders.push(provider); + }, + registerImageGenerationProvider: (provider) => { + imageProviders.push(provider); + }, + }), + ); + + return { providers, speechProviders, mediaProviders, imageProviders }; +} + +describe("openrouter plugin", () => { + it("registers the expected provider surfaces", () => { + const { providers, speechProviders, mediaProviders, imageProviders } = + registerOpenRouterPlugin(); + + expect(providers).toHaveLength(1); + expect( + providers.map( + (provider) => + // oxlint-disable-next-line typescript/no-explicit-any + (provider as any).id, + ), + ).toEqual(["openrouter"]); + expect(speechProviders).toHaveLength(0); + expect(mediaProviders).toHaveLength(0); + expect(imageProviders).toHaveLength(0); + }); +}); + +describeLive("openrouter plugin live", () => { + it("registers an OpenRouter provider that can complete a live request", async () => { + const { providers } = registerOpenRouterPlugin(); + const provider = + // oxlint-disable-next-line typescript/no-explicit-any + providers.find((entry) => (entry as any).id === "openrouter"); + + expect(provider).toBeDefined(); + + // oxlint-disable-next-line typescript/no-explicit-any + const resolved = (provider as any).resolveDynamicModel?.({ + provider: "openrouter", + modelId: LIVE_MODEL_ID, + modelRegistry: { + find() { + return null; + }, + }, + }); + + expect(resolved).toMatchObject({ + provider: "openrouter", + id: LIVE_MODEL_ID, + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + }); + + const client = new OpenAI({ + apiKey: OPENROUTER_API_KEY, + baseURL: resolved?.baseUrl, + }); + const response = await client.chat.completions.create({ + model: resolved?.id ?? LIVE_MODEL_ID, + messages: [{ role: "user", content: "Reply with exactly OK." }], + max_tokens: 16, + }); + + expect(response.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/); + }, 30_000); +});