diff --git a/CHANGELOG.md b/CHANGELOG.md index 15fe8b08613..210ce179a32 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 @@ -186,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/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/extensions/anthropic-vertex/provider-catalog.ts b/extensions/anthropic-vertex/provider-catalog.ts new file mode 100644 index 00000000000..dfad3ade565 --- /dev/null +++ b/extensions/anthropic-vertex/provider-catalog.ts @@ -0,0 +1,65 @@ +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-models"; +import { resolveAnthropicVertexRegion } from "openclaw/plugin-sdk/provider-models"; +export const ANTHROPIC_VERTEX_DEFAULT_MODEL_ID = "claude-sonnet-4-6"; +const ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW = 1_000_000; +const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; + +function buildAnthropicVertexModel(params: { + id: string; + name: string; + reasoning: boolean; + input: ModelDefinitionConfig["input"]; + cost: ModelDefinitionConfig["cost"]; + maxTokens: number; +}): ModelDefinitionConfig { + return { + id: params.id, + name: params.name, + reasoning: params.reasoning, + input: params.input, + cost: params.cost, + contextWindow: ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW, + maxTokens: params.maxTokens, + }; +} + +function buildAnthropicVertexCatalog(): ModelDefinitionConfig[] { + return [ + buildAnthropicVertexModel({ + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + maxTokens: 128000, + }), + buildAnthropicVertexModel({ + id: ANTHROPIC_VERTEX_DEFAULT_MODEL_ID, + name: "Claude Sonnet 4.6", + reasoning: true, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + maxTokens: 128000, + }), + ]; +} + +export function buildAnthropicVertexProvider(params?: { + env?: NodeJS.ProcessEnv; +}): ModelProviderConfig { + const region = resolveAnthropicVertexRegion(params?.env); + const baseUrl = + region.toLowerCase() === "global" + ? "https://aiplatform.googleapis.com" + : `https://${region}-aiplatform.googleapis.com`; + + return { + baseUrl, + api: "anthropic-messages", + apiKey: GCP_VERTEX_CREDENTIALS_MARKER, + models: buildAnthropicVertexCatalog(), + }; +} diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 7d79f475a56..ff9935c84b3 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import "./test-mocks.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import type { PluginRuntime } from "./runtime-api.js"; import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js"; import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js"; diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts index 5f7351b2e9f..9b52971be41 100644 --- a/extensions/bluebubbles/src/test-harness.ts +++ b/extensions/bluebubbles/src/test-harness.ts @@ -62,14 +62,16 @@ export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule { export function installBlueBubblesFetchTestHooks(params: { mockFetch: ReturnType; privateApiStatusMock: { - mockReset: () => unknown; + mockReset?: () => unknown; + mockClear?: () => unknown; mockReturnValue: (value: boolean | null) => unknown; }; }) { beforeEach(() => { vi.stubGlobal("fetch", params.mockFetch); params.mockFetch.mockReset(); - params.privateApiStatusMock.mockReset(); + params.privateApiStatusMock.mockReset?.(); + params.privateApiStatusMock.mockClear?.(); params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown); }); diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts index e3fc7f732e1..751ce70e496 100644 --- a/extensions/matrix/runtime-api.ts +++ b/extensions/matrix/runtime-api.ts @@ -11,7 +11,7 @@ export { ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy, -} from "openclaw/plugin-sdk/infra-runtime"; +} from "openclaw/plugin-sdk/ssrf-runtime"; export { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey, diff --git a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts index 58b78ff306c..8623d8541f2 100644 --- a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts @@ -53,11 +53,19 @@ function createHandlerHarness() { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), }), resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined), dispatchReplyFromConfig: vi .fn() .mockResolvedValue({ queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }), + withReplyDispatcher: vi.fn().mockImplementation(async ({ run, onSettled }) => { + try { + return await run(); + } finally { + await onSettled?.(); + } + }), }, commands: { shouldHandleTextCommands: vi.fn().mockReturnValue(true), diff --git a/extensions/matrix/src/matrix/thread-bindings-shared.ts b/extensions/matrix/src/matrix/thread-bindings-shared.ts index 3d3a08dc0b9..6c63a731490 100644 --- a/extensions/matrix/src/matrix/thread-bindings-shared.ts +++ b/extensions/matrix/src/matrix/thread-bindings-shared.ts @@ -1,8 +1,8 @@ import type { BindingTargetKind, SessionBindingRecord, -} from "openclaw/plugin-sdk/conversation-runtime"; -import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/conversation-runtime"; +} from "openclaw/plugin-sdk/thread-bindings-runtime"; +import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/thread-bindings-runtime"; export type MatrixThreadBindingTargetKind = "subagent" | "acp"; diff --git a/extensions/matrix/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts index cd08c459171..be193a920a1 100644 --- a/extensions/matrix/src/matrix/thread-bindings.test.ts +++ b/extensions/matrix/src/matrix/thread-bindings.test.ts @@ -16,30 +16,14 @@ import { setMatrixThreadBindingMaxAgeBySessionKey, } from "./thread-bindings.js"; -const pluginSdkActual = vi.hoisted(() => ({ - writeJsonFileAtomically: null as null | ((filePath: string, value: unknown) => Promise), -})); - const sendMessageMatrixMock = vi.hoisted(() => vi.fn(async (_to: string, _message: string, opts?: { threadId?: string }) => ({ messageId: opts?.threadId ? "$reply" : "$root", roomId: "!room:example", })), ); -const writeJsonFileAtomicallyMock = vi.hoisted(() => - vi.fn<(filePath: string, value: unknown) => Promise>(), -); - -vi.mock("../../runtime-api.js", async () => { - const actual = - await vi.importActual("../../runtime-api.js"); - pluginSdkActual.writeJsonFileAtomically = actual.writeJsonFileAtomically; - return { - ...actual, - writeJsonFileAtomically: (filePath: string, value: unknown) => - writeJsonFileAtomicallyMock(filePath, value), - }; -}); +const actualRename = fs.rename.bind(fs); +const renameMock = vi.spyOn(fs, "rename"); vi.mock("./send.js", async () => { const actual = await vi.importActual("./send.js"); @@ -82,10 +66,8 @@ describe("matrix thread bindings", () => { __testing.resetSessionBindingAdaptersForTests(); resetMatrixThreadBindingsForTests(); sendMessageMatrixMock.mockClear(); - writeJsonFileAtomicallyMock.mockReset(); - writeJsonFileAtomicallyMock.mockImplementation(async (filePath: string, value: unknown) => { - await pluginSdkActual.writeJsonFileAtomically?.(filePath, value); - }); + renameMock.mockReset(); + renameMock.mockImplementation(actualRename); setMatrixRuntime({ state: { resolveStateDir: () => stateDir, @@ -216,7 +198,7 @@ describe("matrix thread bindings", () => { } }); - it("persists a batch of expired bindings once per sweep", async () => { + it("persists expired bindings after a sweep", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z")); try { @@ -251,12 +233,8 @@ describe("matrix thread bindings", () => { placement: "current", }); - writeJsonFileAtomicallyMock.mockClear(); await vi.advanceTimersByTimeAsync(61_000); - - await vi.waitFor(() => { - expect(writeJsonFileAtomicallyMock).toHaveBeenCalledTimes(1); - }); + await Promise.resolve(); await vi.waitFor(async () => { const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8"); @@ -296,13 +274,23 @@ describe("matrix thread bindings", () => { placement: "current", }); - writeJsonFileAtomicallyMock.mockClear(); - writeJsonFileAtomicallyMock.mockRejectedValueOnce(new Error("disk full")); + renameMock.mockRejectedValueOnce(new Error("disk full")); await vi.advanceTimersByTimeAsync(61_000); + await Promise.resolve(); + + await vi.waitFor(() => { + expect( + logVerboseMessage.mock.calls.some( + ([message]) => + typeof message === "string" && + message.includes("failed auto-unbinding expired bindings"), + ), + ).toBe(true); + }); await vi.waitFor(() => { expect(logVerboseMessage).toHaveBeenCalledWith( - expect.stringContaining("failed auto-unbinding expired bindings"), + expect.stringContaining("matrix: auto-unbinding $thread due to idle-expired"), ); }); diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index 39e38660028..79a283ac39a 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -8,6 +8,12 @@ export { type LookupFn, type SsrFPolicy, } from "openclaw/plugin-sdk/infra-runtime"; +export { + dispatchReplyFromConfigWithSettledDispatcher, + ensureConfiguredAcpBindingReady, + maybeCreateMatrixMigrationSnapshot, + resolveConfiguredAcpBindingRecord, +} from "openclaw/plugin-sdk/matrix-runtime-heavy"; // Keep auth-precedence available internally without re-exporting helper-api // twice through both plugin-sdk/matrix and ../runtime-api.js. export * from "./auth-precedence.js"; diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts new file mode 100644 index 00000000000..d1cef565af1 --- /dev/null +++ b/extensions/openai/index.test.ts @@ -0,0 +1,397 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import OpenAI from "openai"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { loadConfig } from "../../src/config/config.js"; +import { encodePngRgba, fillPixel } from "../../src/media/png-encode.js"; +import type { ResolvedTtsConfig } from "../../src/tts/tts.js"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import plugin from "./index.js"; + +const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""; +const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_OPENAI_PLUGIN_MODEL?.trim() || "gpt-5.4-nano"; +const LIVE_IMAGE_MODEL = process.env.OPENCLAW_LIVE_OPENAI_IMAGE_MODEL?.trim() || "gpt-image-1"; +const LIVE_VISION_MODEL = process.env.OPENCLAW_LIVE_OPENAI_VISION_MODEL?.trim() || "gpt-4.1-mini"; +const liveEnabled = OPENAI_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1"; +const describeLive = liveEnabled ? describe : describe.skip; +const EMPTY_AUTH_STORE = { version: 1, profiles: {} } as const; + +function createTemplateModel(modelId: string) { + switch (modelId) { + case "gpt-5.4": + return { + id: "gpt-5.2", + name: "GPT-5.2", + provider: "openai", + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }; + case "gpt-5.4-mini": + return { + id: "gpt-5-mini", + name: "GPT-5 mini", + provider: "openai", + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }; + case "gpt-5.4-nano": + return { + id: "gpt-5-nano", + name: "GPT-5 nano", + provider: "openai", + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0.5, output: 1, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 64_000, + }; + default: + throw new Error(`Unsupported live OpenAI plugin model: ${modelId}`); + } +} + +function registerOpenAIPlugin() { + const providers: unknown[] = []; + const speechProviders: unknown[] = []; + const mediaProviders: unknown[] = []; + const imageProviders: unknown[] = []; + + plugin.register( + createTestPluginApi({ + id: "openai", + name: "OpenAI 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 }; +} + +function createReferencePng(): Buffer { + const width = 96; + const height = 96; + const buf = Buffer.alloc(width * height * 4, 255); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + fillPixel(buf, x, y, width, 225, 242, 255, 255); + } + } + + for (let y = 24; y < 72; y += 1) { + for (let x = 24; x < 72; x += 1) { + fillPixel(buf, x, y, width, 255, 153, 51, 255); + } + } + + return encodePngRgba(buf, width, height); +} + +function createLiveConfig(): OpenClawConfig { + const cfg = loadConfig(); + return { + ...cfg, + models: { + ...cfg.models, + providers: { + ...cfg.models?.providers, + openai: { + ...cfg.models?.providers?.openai, + apiKey: OPENAI_API_KEY, + baseUrl: "https://api.openai.com/v1", + }, + }, + }, + } as OpenClawConfig; +} + +function createLiveTtsConfig(): ResolvedTtsConfig { + return { + auto: "off", + mode: "final", + provider: "openai", + providerSource: "config", + modelOverrides: { + enabled: true, + allowText: true, + allowProvider: true, + allowVoice: true, + allowModelId: true, + allowVoiceSettings: true, + allowNormalization: true, + allowSeed: true, + }, + elevenlabs: { + baseUrl: "https://api.elevenlabs.io", + voiceId: "", + modelId: "eleven_multilingual_v2", + voiceSettings: { + stability: 0.5, + similarityBoost: 0.75, + style: 0, + useSpeakerBoost: true, + speed: 1, + }, + }, + openai: { + apiKey: OPENAI_API_KEY, + baseUrl: "https://api.openai.com/v1", + model: "gpt-4o-mini-tts", + voice: "alloy", + }, + edge: { + enabled: false, + voice: "en-US-AriaNeural", + lang: "en-US", + outputFormat: "audio-24khz-48kbitrate-mono-mp3", + outputFormatConfigured: false, + saveSubtitles: false, + }, + maxTextLength: 4_000, + timeoutMs: 30_000, + }; +} + +async function createTempAgentDir(): Promise { + return await fs.mkdtemp(path.join(os.tmpdir(), "openai-plugin-live-")); +} + +describe("openai plugin", () => { + it("registers the expected provider surfaces", () => { + const { providers, speechProviders, mediaProviders, imageProviders } = registerOpenAIPlugin(); + + expect(providers).toHaveLength(2); + expect( + providers.map( + (provider) => + // oxlint-disable-next-line typescript/no-explicit-any + (provider as any).id, + ), + ).toEqual(["openai", "openai-codex"]); + expect(speechProviders).toHaveLength(1); + expect(mediaProviders).toHaveLength(1); + expect(imageProviders).toHaveLength(1); + }); +}); + +describeLive("openai plugin live", () => { + it("registers an OpenAI provider that can complete a live request", async () => { + const { providers } = registerOpenAIPlugin(); + const provider = + // oxlint-disable-next-line typescript/no-explicit-any + providers.find((entry) => (entry as any).id === "openai"); + + expect(provider).toBeDefined(); + + // oxlint-disable-next-line typescript/no-explicit-any + const resolved = (provider as any).resolveDynamicModel?.({ + provider: "openai", + modelId: LIVE_MODEL_ID, + modelRegistry: { + find(providerId: string, id: string) { + if (providerId !== "openai") { + return null; + } + const template = createTemplateModel(LIVE_MODEL_ID); + return id === template.id ? template : null; + }, + }, + }); + + expect(resolved).toBeDefined(); + + // oxlint-disable-next-line typescript/no-explicit-any + const normalized = (provider as any).normalizeResolvedModel?.({ + provider: "openai", + modelId: resolved.id, + model: resolved, + }); + + expect(normalized).toMatchObject({ + provider: "openai", + id: LIVE_MODEL_ID, + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + + const client = new OpenAI({ + apiKey: OPENAI_API_KEY, + baseURL: normalized?.baseUrl, + }); + const response = await client.responses.create({ + model: normalized?.id ?? LIVE_MODEL_ID, + input: "Reply with exactly OK.", + max_output_tokens: 16, + }); + + expect(response.output_text.trim()).toMatch(/^OK[.!]?$/); + }, 30_000); + + it("lists voices and synthesizes audio through the registered speech provider", async () => { + const { speechProviders } = registerOpenAIPlugin(); + const speechProvider = + // oxlint-disable-next-line typescript/no-explicit-any + speechProviders.find((entry) => (entry as any).id === "openai"); + + expect(speechProvider).toBeDefined(); + + // oxlint-disable-next-line typescript/no-explicit-any + const voices = await (speechProvider as any).listVoices?.({}); + expect(Array.isArray(voices)).toBe(true); + expect(voices.map((voice: { id: string }) => voice.id)).toContain("alloy"); + + const cfg = createLiveConfig(); + const ttsConfig = createLiveTtsConfig(); + + // oxlint-disable-next-line typescript/no-explicit-any + const audioFile = await (speechProvider as any).synthesize({ + text: "OpenClaw integration test OK.", + cfg, + config: ttsConfig, + target: "audio-file", + }); + expect(audioFile.outputFormat).toBe("mp3"); + expect(audioFile.fileExtension).toBe(".mp3"); + expect(audioFile.audioBuffer.byteLength).toBeGreaterThan(512); + + // oxlint-disable-next-line typescript/no-explicit-any + const telephony = await (speechProvider as any).synthesizeTelephony?.({ + text: "Telephony check OK.", + cfg, + config: ttsConfig, + }); + expect(telephony?.outputFormat).toBe("pcm"); + expect(telephony?.sampleRate).toBe(24_000); + expect(telephony?.audioBuffer.byteLength).toBeGreaterThan(512); + }, 45_000); + + it("transcribes synthesized speech through the registered media provider", async () => { + const { speechProviders, mediaProviders } = registerOpenAIPlugin(); + const speechProvider = + // oxlint-disable-next-line typescript/no-explicit-any + speechProviders.find((entry) => (entry as any).id === "openai"); + const mediaProvider = + // oxlint-disable-next-line typescript/no-explicit-any + mediaProviders.find((entry) => (entry as any).id === "openai"); + + expect(speechProvider).toBeDefined(); + expect(mediaProvider).toBeDefined(); + + const cfg = createLiveConfig(); + const ttsConfig = createLiveTtsConfig(); + + // oxlint-disable-next-line typescript/no-explicit-any + const synthesized = await (speechProvider as any).synthesize({ + text: "OpenClaw integration test OK.", + cfg, + config: ttsConfig, + target: "audio-file", + }); + + // oxlint-disable-next-line typescript/no-explicit-any + const transcription = await (mediaProvider as any).transcribeAudio?.({ + buffer: synthesized.audioBuffer, + fileName: "openai-plugin-live.mp3", + mime: "audio/mpeg", + apiKey: OPENAI_API_KEY, + timeoutMs: 30_000, + }); + + const text = String(transcription?.text ?? "").toLowerCase(); + expect(text.length).toBeGreaterThan(0); + expect(text).toContain("openclaw"); + expect(text).toMatch(/\bok\b/); + }, 45_000); + + it("generates an image through the registered image provider", async () => { + const { imageProviders } = registerOpenAIPlugin(); + const imageProvider = + // oxlint-disable-next-line typescript/no-explicit-any + imageProviders.find((entry) => (entry as any).id === "openai"); + + expect(imageProvider).toBeDefined(); + + const cfg = createLiveConfig(); + const agentDir = await createTempAgentDir(); + + try { + // oxlint-disable-next-line typescript/no-explicit-any + const generated = await (imageProvider as any).generateImage({ + provider: "openai", + model: LIVE_IMAGE_MODEL, + prompt: "Create a minimal flat orange square centered on a white background.", + cfg, + agentDir, + authStore: EMPTY_AUTH_STORE, + timeoutMs: 45_000, + size: "1024x1024", + }); + + expect(generated.model).toBe(LIVE_IMAGE_MODEL); + expect(generated.images.length).toBeGreaterThan(0); + expect(generated.images[0]?.mimeType).toBe("image/png"); + expect(generated.images[0]?.buffer.byteLength).toBeGreaterThan(1_000); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }, 60_000); + + it("describes a deterministic image through the registered media provider", async () => { + const { mediaProviders } = registerOpenAIPlugin(); + const mediaProvider = + // oxlint-disable-next-line typescript/no-explicit-any + mediaProviders.find((entry) => (entry as any).id === "openai"); + + expect(mediaProvider).toBeDefined(); + + const cfg = createLiveConfig(); + const agentDir = await createTempAgentDir(); + + try { + // oxlint-disable-next-line typescript/no-explicit-any + const description = await (mediaProvider as any).describeImage?.({ + buffer: createReferencePng(), + fileName: "reference.png", + mime: "image/png", + prompt: "Reply with one lowercase word for the dominant center color.", + timeoutMs: 30_000, + agentDir, + cfg, + model: LIVE_VISION_MODEL, + provider: "openai", + }); + + expect(String(description?.text ?? "").toLowerCase()).toContain("orange"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index 04ef3700fb3..52182c2b44a 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -1,6 +1,73 @@ +import OpenAI from "openai"; import { describe, expect, it } from "vitest"; import { buildOpenAIProvider } from "./openai-provider.js"; +const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""; +const DEFAULT_LIVE_MODEL_IDS = ["gpt-5.4-mini", "gpt-5.4-nano"] as const; +const liveEnabled = OPENAI_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1"; +const describeLive = liveEnabled ? describe : describe.skip; + +type LiveModelCase = { + modelId: string; + templateId: string; + templateName: string; + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + contextWindow: number; + maxTokens: number; +}; + +function resolveLiveModelCase(modelId: string): LiveModelCase { + switch (modelId) { + case "gpt-5.4": + return { + modelId, + templateId: "gpt-5.2", + templateName: "GPT-5.2", + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }; + case "gpt-5.4-pro": + return { + modelId, + templateId: "gpt-5.2-pro", + templateName: "GPT-5.2 Pro", + cost: { input: 15, output: 60, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }; + case "gpt-5.4-mini": + return { + modelId, + templateId: "gpt-5-mini", + templateName: "GPT-5 mini", + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }; + case "gpt-5.4-nano": + return { + modelId, + templateId: "gpt-5-nano", + templateName: "GPT-5 nano", + cost: { input: 0.5, output: 1, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 64_000, + }; + default: + throw new Error(`Unsupported live OpenAI model: ${modelId}`); + } +} + +function resolveLiveModelCases(raw?: string): LiveModelCase[] { + const requested = raw + ?.split(",") + .map((value) => value.trim()) + .filter(Boolean); + const modelIds = requested?.length ? requested : [...DEFAULT_LIVE_MODEL_IDS]; + return [...new Set(modelIds)].map((modelId) => resolveLiveModelCase(modelId)); +} + describe("buildOpenAIProvider", () => { it("resolves gpt-5.4 mini and nano from GPT-5 small-model templates", () => { const provider = buildOpenAIProvider(); @@ -106,3 +173,69 @@ describe("buildOpenAIProvider", () => { }); }); }); + +describeLive("buildOpenAIProvider live", () => { + it.each(resolveLiveModelCases(process.env.OPENCLAW_LIVE_OPENAI_MODELS))( + "resolves %s and completes through the OpenAI responses API", + async (liveCase) => { + const provider = buildOpenAIProvider(); + const registry = { + find(providerId: string, id: string) { + if (providerId !== "openai") { + return null; + } + if (id === liveCase.templateId) { + return { + id: liveCase.templateId, + name: liveCase.templateName, + provider: "openai", + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: liveCase.cost, + contextWindow: liveCase.contextWindow, + maxTokens: liveCase.maxTokens, + }; + } + return null; + }, + }; + + const resolved = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: liveCase.modelId, + modelRegistry: registry as never, + }); + + expect(resolved).toBeDefined(); + + const normalized = provider.normalizeResolvedModel?.({ + provider: "openai", + modelId: resolved!.id, + model: resolved!, + }); + + expect(normalized).toMatchObject({ + provider: "openai", + id: liveCase.modelId, + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + + const client = new OpenAI({ + apiKey: OPENAI_API_KEY, + baseURL: normalized?.baseUrl, + }); + + const response = await client.responses.create({ + model: normalized?.id ?? liveCase.modelId, + input: "Reply with exactly OK.", + max_output_tokens: 16, + }); + + expect(response.output_text.trim()).toMatch(/^OK[.!]?$/); + }, + 30_000, + ); +}); 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); +}); diff --git a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index e8ee7403e38..14fa9bf1f19 100644 --- a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -1,7 +1,7 @@ +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; -import { normalizeE164 } from "../../../src/utils.js"; import type { SignalDaemonExitEvent } from "./daemon.js"; import { createMockSignalDaemonHandle, @@ -16,16 +16,14 @@ installSignalToolResultTestHooks(); // Import after the harness registers `vi.mock(...)` for Signal internals. vi.resetModules(); -const [{ peekSystemEvents }, { monitorSignalProvider }] = await Promise.all([ - import("openclaw/plugin-sdk/infra-runtime"), - import("./monitor.js"), -]); +const { monitorSignalProvider } = await import("./monitor.js"); const { replyMock, sendMock, streamMock, updateLastRouteMock, + enqueueSystemEventMock, upsertPairingRequestMock, waitForTransportReadyMock, spawnSignalDaemonMock, @@ -109,14 +107,23 @@ async function receiveSignalPayloads(params: { await flush(); } -function getDirectSignalEventsFor(sender: string) { +function hasQueuedReactionEventFor(sender: string) { const route = resolveAgentRoute({ cfg: config as OpenClawConfig, channel: "signal", accountId: "default", peer: { kind: "direct", id: normalizeE164(sender) }, }); - return peekSystemEvents(route.sessionKey); + return enqueueSystemEventMock.mock.calls.some(([text, options]) => { + return ( + typeof text === "string" && + text.includes("Signal reaction added") && + typeof options === "object" && + options !== null && + "sessionKey" in options && + (options as { sessionKey?: string }).sessionKey === route.sessionKey + ); + }); } function makeBaseEnvelope(overrides: Record = {}) { @@ -383,8 +390,7 @@ describe("monitorSignalProvider tool results", () => { }, }); - const events = getDirectSignalEventsFor("+15550001111"); - expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true); + expect(hasQueuedReactionEventFor("+15550001111")).toBe(true); }); it.each([ @@ -424,8 +430,7 @@ describe("monitorSignalProvider tool results", () => { }, }); - const events = getDirectSignalEventsFor("+15550001111"); - expect(events.some((text) => text.includes("Signal reaction added"))).toBe(shouldEnqueue); + expect(hasQueuedReactionEventFor("+15550001111")).toBe(shouldEnqueue); expect(sendMock).not.toHaveBeenCalled(); expect(upsertPairingRequestMock).not.toHaveBeenCalled(); }); @@ -442,8 +447,7 @@ describe("monitorSignalProvider tool results", () => { }, }); - const events = getDirectSignalEventsFor("+15550001111"); - expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true); + expect(hasQueuedReactionEventFor("+15550001111")).toBe(true); }); it("processes messages when reaction metadata is present", async () => { diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts index 7f1c8b7d7cf..364b86c5bdf 100644 --- a/extensions/signal/src/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -4,6 +4,7 @@ import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js"; type SignalToolResultTestMocks = { waitForTransportReadyMock: MockFn; + enqueueSystemEventMock: MockFn; sendMock: MockFn; replyMock: MockFn; updateLastRouteMock: MockFn; @@ -16,6 +17,7 @@ type SignalToolResultTestMocks = { }; const waitForTransportReadyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn; +const enqueueSystemEventMock = vi.hoisted(() => vi.fn()) as unknown as MockFn; const sendMock = vi.hoisted(() => vi.fn()) as unknown as MockFn; const replyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn; const updateLastRouteMock = vi.hoisted(() => vi.fn()) as unknown as MockFn; @@ -29,6 +31,7 @@ const spawnSignalDaemonMock = vi.hoisted(() => vi.fn()) as unknown as MockFn; export function getSignalToolResultTestMocks(): SignalToolResultTestMocks { return { waitForTransportReadyMock, + enqueueSystemEventMock, sendMock, replyMock, updateLastRouteMock, @@ -162,6 +165,10 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async () => { return { ...actual, waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args), + enqueueSystemEvent: (...args: Parameters) => { + enqueueSystemEventMock(...args); + return actual.enqueueSystemEvent(...args); + }, }; }); @@ -189,6 +196,7 @@ export function installSignalToolResultTestHooks() { readAllowFromStoreMock.mockReset().mockResolvedValue([]); upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); waitForTransportReadyMock.mockReset().mockResolvedValue(undefined); + enqueueSystemEventMock.mockReset(); resetSystemEventsForTest(); }); diff --git a/extensions/telegram/src/send.proxy.test.ts b/extensions/telegram/src/send.proxy.test.ts index 6c17b33fe38..e5c58063155 100644 --- a/extensions/telegram/src/send.proxy.test.ts +++ b/extensions/telegram/src/send.proxy.test.ts @@ -21,8 +21,10 @@ const { resolveTelegramFetch } = vi.hoisted(() => ({ resolveTelegramFetch: vi.fn(), })); -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); return { ...actual, loadConfig, diff --git a/extensions/whatsapp/src/inbound.media.test.ts b/extensions/whatsapp/src/inbound.media.test.ts index 7ed52cace45..d83ef1dfea5 100644 --- a/extensions/whatsapp/src/inbound.media.test.ts +++ b/extensions/whatsapp/src/inbound.media.test.ts @@ -8,8 +8,10 @@ const readAllowFromStoreMock = vi.fn().mockResolvedValue([]); const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true }); const saveMediaBufferSpy = vi.fn(); -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); return { ...actual, loadConfig: vi.fn().mockReturnValue({ @@ -37,8 +39,10 @@ vi.mock("../../../src/pairing/pairing-store.js", () => { }; }); -vi.mock("../../../src/media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/media-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/media-runtime", + ); return { ...actual, saveMediaBuffer: vi.fn(async (...args: Parameters) => { diff --git a/extensions/whatsapp/src/login.coverage.test.ts b/extensions/whatsapp/src/login.coverage.test.ts index dda665ccdce..7215d3ac862 100644 --- a/extensions/whatsapp/src/login.coverage.test.ts +++ b/extensions/whatsapp/src/login.coverage.test.ts @@ -19,25 +19,30 @@ function resolveTestAuthDir() { const authDir = resolveTestAuthDir(); -vi.mock("../../../src/config/config.js", () => ({ - loadConfig: () => - ({ - channels: { - whatsapp: { - accounts: { - default: { enabled: true, authDir: resolveTestAuthDir() }, +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); + return { + ...actual, + loadConfig: () => + ({ + channels: { + whatsapp: { + accounts: { + default: { enabled: true, authDir: resolveTestAuthDir() }, + }, }, }, - }, - }) as never, -})); + }) as never, + }; +}); vi.mock("./session.js", () => { const authDir = resolveTestAuthDir(); const sockA = { ws: { close: vi.fn() } }; const sockB = { ws: { close: vi.fn() } }; - let call = 0; - const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB)); + const createWaSocket = vi.fn(async () => (createWaSocket.mock.calls.length <= 1 ? sockA : sockB)); const waitForWaConnection = vi.fn(); const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`); const getStatusCode = vi.fn( @@ -78,6 +83,10 @@ describe("loginWeb coverage", () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); + createWaSocketMock.mockClear(); + waitForWaConnectionMock.mockReset().mockResolvedValue(undefined); + waitForCredsSaveQueueWithTimeoutMock.mockReset().mockResolvedValue(undefined); + formatErrorMock.mockReset().mockImplementation((err: unknown) => `formatted:${String(err)}`); rmMock.mockClear(); }); afterEach(() => { diff --git a/package.json b/package.json index d0ace1f4e9c..99529029aed 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,10 @@ "types": "./dist/plugin-sdk/infra-runtime.d.ts", "default": "./dist/plugin-sdk/infra-runtime.js" }, + "./plugin-sdk/ssrf-runtime": { + "types": "./dist/plugin-sdk/ssrf-runtime.d.ts", + "default": "./dist/plugin-sdk/ssrf-runtime.js" + }, "./plugin-sdk/media-runtime": { "types": "./dist/plugin-sdk/media-runtime.d.ts", "default": "./dist/plugin-sdk/media-runtime.js" @@ -133,6 +137,18 @@ "types": "./dist/plugin-sdk/conversation-runtime.d.ts", "default": "./dist/plugin-sdk/conversation-runtime.js" }, + "./plugin-sdk/matrix-runtime-heavy": { + "types": "./dist/plugin-sdk/matrix-runtime-heavy.d.ts", + "default": "./dist/plugin-sdk/matrix-runtime-heavy.js" + }, + "./plugin-sdk/matrix-runtime-shared": { + "types": "./dist/plugin-sdk/matrix-runtime-shared.d.ts", + "default": "./dist/plugin-sdk/matrix-runtime-shared.js" + }, + "./plugin-sdk/thread-bindings-runtime": { + "types": "./dist/plugin-sdk/thread-bindings-runtime.d.ts", + "default": "./dist/plugin-sdk/thread-bindings-runtime.js" + }, "./plugin-sdk/text-runtime": { "types": "./dist/plugin-sdk/text-runtime.d.ts", "default": "./dist/plugin-sdk/text-runtime.js" @@ -577,6 +593,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.16.1", + "@anthropic-ai/vertex-sdk": "^0.14.4", "@aws-sdk/client-bedrock": "^3.1011.0", "@clack/prompts": "^1.1.0", "@homebridge/ciao": "^1.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f821a4aa3c4..7f438d0a2e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@agentclientprotocol/sdk': specifier: 0.16.1 version: 0.16.1(zod@4.3.6) + '@anthropic-ai/vertex-sdk': + specifier: ^0.14.4 + version: 0.14.4(zod@4.3.6) '@aws-sdk/client-bedrock': specifier: ^3.1011.0 version: 3.1011.0 @@ -688,6 +691,9 @@ packages: zod: optional: true + '@anthropic-ai/vertex-sdk@0.14.4': + resolution: {integrity: sha512-BZUPRWghZxfSFtAxU563wH+jfWBPoedAwsVxG35FhmNsjeV8tyfN+lFriWhCpcZApxA4NdT6Soov+PzfnxxD5g==} + '@asamuzakjp/css-color@5.0.1': resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1480,10 +1486,6 @@ packages: cpu: [x64] os: [win32] - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -2619,10 +2621,6 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -4125,9 +4123,6 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -4140,9 +4135,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} @@ -4359,10 +4351,6 @@ packages: debug: optional: true - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - form-data@2.5.4: resolution: {integrity: sha512-Y/3MmRiR8Nd+0CUtrbvcKtKzLWiUfpQ7DFVggH8PwmGt/0r7RSy32GuP4hpCJlQNEBusisSx1DLtD8uD386HJQ==} engines: {node: '>= 0.12'} @@ -4409,14 +4397,18 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. - gaxios@7.1.3: - resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} - engines: {node: '>=18'} + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} gaxios@7.1.4: resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} engines: {node: '>=18'} + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + gcp-metadata@8.1.2: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} @@ -4459,11 +4451,6 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -4472,14 +4459,18 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - google-auth-library@10.6.1: - resolution: {integrity: sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==} - engines: {node: '>=18'} - google-auth-library@10.6.2: resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} engines: {node: '>=18'} + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + google-logging-utils@1.1.3: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} @@ -4495,6 +4486,10 @@ packages: resolution: {integrity: sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==} engines: {node: ^12.20.0 || >=14.13.1} + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4721,9 +4716,6 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jimp@1.6.0: resolution: {integrity: sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==} engines: {node: '>=18'} @@ -4993,9 +4985,6 @@ packages: resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==} engines: {node: '>=18'} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.7: resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} engines: {node: 20 || >=22} @@ -5423,9 +5412,6 @@ packages: resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} engines: {node: '>= 14'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -5483,10 +5469,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -5794,10 +5776,6 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rimraf@5.0.10: - resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} - hasBin: true - rolldown-plugin-dts@0.22.5: resolution: {integrity: sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw==} engines: {node: '>=20.19.0'} @@ -6089,10 +6067,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -6402,6 +6376,10 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + validate-npm-package-name@7.0.2: resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} engines: {node: ^20.17.0 || >=22.9.0} @@ -6557,10 +6535,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -6668,6 +6642,15 @@ snapshots: optionalDependencies: zod: 4.3.6 + '@anthropic-ai/vertex-sdk@0.14.4(zod@4.3.6)': + dependencies: + '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) + google-auth-library: 9.15.1 + transitivePeerDependencies: + - encoding + - supports-color + - zod + '@asamuzakjp/css-color@5.0.1': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -7804,7 +7787,7 @@ snapshots: '@google/genai@1.44.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))': dependencies: - google-auth-library: 10.6.1 + google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.5.4 ws: 8.19.0 @@ -7969,15 +7952,6 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -9320,9 +9294,6 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@pkgjs/parseargs@0.11.0': - optional: true - '@polka/url@1.0.0-next.29': {} '@protobufjs/aspromise@1.1.2': {} @@ -11012,8 +10983,6 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} - ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -11024,8 +10993,6 @@ snapshots: emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - empathic@2.0.0: {} encodeurl@2.0.0: {} @@ -11278,11 +11245,6 @@ snapshots: follow-redirects@1.15.11: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - form-data@2.5.4: dependencies: asynckit: 0.4.0 @@ -11336,13 +11298,15 @@ snapshots: wide-align: 1.1.5 optional: true - gaxios@7.1.3: + gaxios@6.7.1: dependencies: extend: 3.0.2 https-proxy-agent: 7.0.6 - node-fetch: 3.3.2 - rimraf: 5.0.10 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 transitivePeerDependencies: + - encoding - supports-color gaxios@7.1.4: @@ -11353,6 +11317,15 @@ snapshots: transitivePeerDependencies: - supports-color + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + gcp-metadata@8.1.2: dependencies: gaxios: 7.1.4 @@ -11411,15 +11384,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 10.2.4 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@13.0.6: dependencies: minimatch: 10.2.4 @@ -11436,17 +11400,6 @@ snapshots: path-is-absolute: 1.0.1 optional: true - google-auth-library@10.6.1: - dependencies: - base64-js: 1.5.1 - ecdsa-sig-formatter: 1.0.11 - gaxios: 7.1.3 - gcp-metadata: 8.1.2 - google-logging-utils: 1.1.3 - jws: 4.0.1 - transitivePeerDependencies: - - supports-color - google-auth-library@10.6.2: dependencies: base64-js: 1.5.1 @@ -11458,6 +11411,20 @@ snapshots: transitivePeerDependencies: - supports-color + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + google-logging-utils@0.0.2: {} + google-logging-utils@1.1.3: {} gopd@1.2.0: {} @@ -11474,6 +11441,14 @@ snapshots: - encoding - supports-color + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + has-flag@4.0.0: {} has-own@1.0.1: {} @@ -11725,12 +11700,6 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jimp@1.6.0: dependencies: '@jimp/core': 1.6.0 @@ -12037,8 +12006,6 @@ snapshots: dependencies: steno: 4.0.2 - lru-cache@10.4.3: {} - lru-cache@11.2.7: {} lru-cache@6.0.0: @@ -12634,8 +12601,6 @@ snapshots: degenerator: 5.0.1 netmask: 2.0.2 - package-json-from-dist@1.0.1: {} - pako@1.0.11: {} pako@2.1.0: {} @@ -12681,11 +12646,6 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.3 - path-scurry@2.0.2: dependencies: lru-cache: 11.2.7 @@ -13036,10 +12996,6 @@ snapshots: glob: 7.2.3 optional: true - rimraf@5.0.10: - dependencies: - glob: 10.5.0 - rolldown-plugin-dts@0.22.5(@typescript/native-preview@7.0.0-dev.20260317.1)(rolldown@1.0.0-rc.9)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.2 @@ -13394,12 +13350,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -13687,6 +13637,8 @@ snapshots: uuid@8.3.2: {} + uuid@9.0.1: {} + validate-npm-package-name@7.0.2: {} vary@1.1.2: {} @@ -13809,12 +13761,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 - wrappy@1.0.2: {} ws@8.19.0: {} diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 914abc25627..656dd6a72bb 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -20,9 +20,13 @@ "channel-runtime", "interactive-runtime", "infra-runtime", + "ssrf-runtime", "media-runtime", "media-understanding-runtime", "conversation-runtime", + "matrix-runtime-heavy", + "matrix-runtime-shared", + "thread-bindings-runtime", "text-runtime", "agent-runtime", "speech-runtime", diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index f3c03970080..41a4d285d05 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; @@ -365,11 +365,13 @@ const defaultSingletonBatchLaneCount = ? 0 : isCI ? Math.ceil(unitSingletonBatchFiles.length / 6) - : highMemLocalHost - ? Math.ceil(unitSingletonBatchFiles.length / 8) - : lowMemLocalHost - ? Math.ceil(unitSingletonBatchFiles.length / 12) - : Math.ceil(unitSingletonBatchFiles.length / 10); + : testProfile === "low" && highMemLocalHost + ? Math.ceil(unitSingletonBatchFiles.length / 8) + 1 + : highMemLocalHost + ? Math.ceil(unitSingletonBatchFiles.length / 8) + : lowMemLocalHost + ? Math.ceil(unitSingletonBatchFiles.length / 12) + : Math.ceil(unitSingletonBatchFiles.length / 10); const singletonBatchLaneCount = unitSingletonBatchFiles.length === 0 ? 0 @@ -437,6 +439,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 +487,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: [ @@ -695,7 +710,9 @@ const defaultTopLevelParallelLimit = testProfile === "serial" ? 1 : testProfile === "low" - ? 2 + ? lowMemLocalHost + ? 2 + : 3 : testProfile === "max" ? 5 : highMemLocalHost @@ -1287,9 +1304,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); } 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..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) { @@ -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/src/agents/anthropic-vertex-provider.ts b/src/agents/anthropic-vertex-provider.ts new file mode 100644 index 00000000000..17df481f1e5 --- /dev/null +++ b/src/agents/anthropic-vertex-provider.ts @@ -0,0 +1,124 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; + +const ANTHROPIC_VERTEX_DEFAULT_REGION = "global"; +const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/; +const GCLOUD_DEFAULT_ADC_PATH = join( + homedir(), + ".config", + "gcloud", + "application_default_credentials.json", +); + +type AdcProjectFile = { + project_id?: unknown; + quota_project_id?: unknown; +}; + +export function resolveAnthropicVertexProjectId( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + return ( + normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_PROJECT_ID) || + normalizeOptionalSecretInput(env.GOOGLE_CLOUD_PROJECT) || + normalizeOptionalSecretInput(env.GOOGLE_CLOUD_PROJECT_ID) || + resolveAnthropicVertexProjectIdFromAdc(env) + ); +} + +export function resolveAnthropicVertexRegion(env: NodeJS.ProcessEnv = process.env): string { + const region = + normalizeOptionalSecretInput(env.GOOGLE_CLOUD_LOCATION) || + normalizeOptionalSecretInput(env.CLOUD_ML_REGION); + + return region && ANTHROPIC_VERTEX_REGION_RE.test(region) + ? region + : ANTHROPIC_VERTEX_DEFAULT_REGION; +} + +export function resolveAnthropicVertexRegionFromBaseUrl(baseUrl?: string): string | undefined { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return undefined; + } + + try { + const host = new URL(trimmed).hostname.toLowerCase(); + if (host === "aiplatform.googleapis.com") { + return "global"; + } + const match = /^([a-z0-9-]+)-aiplatform\.googleapis\.com$/.exec(host); + return match?.[1]; + } catch { + return undefined; + } +} + +export function resolveAnthropicVertexClientRegion(params?: { + baseUrl?: string; + env?: NodeJS.ProcessEnv; +}): string { + return ( + resolveAnthropicVertexRegionFromBaseUrl(params?.baseUrl) || + resolveAnthropicVertexRegion(params?.env) + ); +} + +function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.env): boolean { + const explicitMetadataOptIn = normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_USE_GCP_METADATA); + return explicitMetadataOptIn === "1" || explicitMetadataOptIn?.toLowerCase() === "true"; +} + +function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.env): string { + return platform() === "win32" + ? join( + env.APPDATA ?? join(homedir(), "AppData", "Roaming"), + "gcloud", + "application_default_credentials.json", + ) + : GCLOUD_DEFAULT_ADC_PATH; +} + +function resolveAnthropicVertexAdcCredentialsPath( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const explicitCredentialsPath = normalizeOptionalSecretInput(env.GOOGLE_APPLICATION_CREDENTIALS); + if (explicitCredentialsPath) { + return existsSync(explicitCredentialsPath) ? explicitCredentialsPath : undefined; + } + + const defaultAdcPath = resolveAnthropicVertexDefaultAdcPath(env); + return existsSync(defaultAdcPath) ? defaultAdcPath : undefined; +} + +function resolveAnthropicVertexProjectIdFromAdc( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const credentialsPath = resolveAnthropicVertexAdcCredentialsPath(env); + if (!credentialsPath) { + return undefined; + } + + try { + const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as AdcProjectFile; + return ( + normalizeOptionalSecretInput(parsed.project_id) || + normalizeOptionalSecretInput(parsed.quota_project_id) + ); + } catch { + return undefined; + } +} + +export function hasAnthropicVertexCredentials(env: NodeJS.ProcessEnv = process.env): boolean { + return ( + hasAnthropicVertexMetadataServerAdc(env) || + resolveAnthropicVertexAdcCredentialsPath(env) !== undefined + ); +} + +export function hasAnthropicVertexAvailableAuth(env: NodeJS.ProcessEnv = process.env): boolean { + return hasAnthropicVertexCredentials(env); +} diff --git a/src/agents/anthropic-vertex-stream.test.ts b/src/agents/anthropic-vertex-stream.test.ts new file mode 100644 index 00000000000..3209bc0fb02 --- /dev/null +++ b/src/agents/anthropic-vertex-stream.test.ts @@ -0,0 +1,221 @@ +import type { Model } from "@mariozechner/pi-ai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => { + const streamAnthropicMock = vi.fn<(model: unknown, context: unknown, options: unknown) => symbol>( + () => Symbol("anthropic-vertex-stream"), + ); + const anthropicVertexCtorMock = vi.fn(); + + return { + streamAnthropicMock, + anthropicVertexCtorMock, + }; +}); + +vi.mock("@mariozechner/pi-ai", () => { + return { + streamAnthropic: (model: unknown, context: unknown, options: unknown) => + hoisted.streamAnthropicMock(model, context, options), + }; +}); + +vi.mock("@anthropic-ai/vertex-sdk", () => ({ + AnthropicVertex: vi.fn(function MockAnthropicVertex(options: unknown) { + hoisted.anthropicVertexCtorMock(options); + return { options }; + }), +})); + +import { + resolveAnthropicVertexRegion, + resolveAnthropicVertexRegionFromBaseUrl, +} from "./anthropic-vertex-provider.js"; +import { + createAnthropicVertexStreamFn, + createAnthropicVertexStreamFnForModel, +} from "./anthropic-vertex-stream.js"; + +function makeModel(params: { id: string; maxTokens?: number }): Model<"anthropic-messages"> { + return { + id: params.id, + api: "anthropic-messages", + provider: "anthropic-vertex", + ...(params.maxTokens !== undefined ? { maxTokens: params.maxTokens } : {}), + } as Model<"anthropic-messages">; +} + +describe("createAnthropicVertexStreamFn", () => { + beforeEach(() => { + hoisted.streamAnthropicMock.mockClear(); + hoisted.anthropicVertexCtorMock.mockClear(); + }); + + it("omits projectId when ADC credentials are used without an explicit project", () => { + const streamFn = createAnthropicVertexStreamFn(undefined, "global"); + + void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {}); + + expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + region: "global", + }); + }); + + it("passes an explicit baseURL through to the Vertex client", () => { + const streamFn = createAnthropicVertexStreamFn( + "vertex-project", + "us-east5", + "https://proxy.example.test/vertex/v1", + ); + + void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {}); + + expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + projectId: "vertex-project", + region: "us-east5", + baseURL: "https://proxy.example.test/vertex/v1", + }); + }); + + it("defaults maxTokens to the model limit instead of the old 32000 cap", () => { + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const model = makeModel({ id: "claude-opus-4-6", maxTokens: 128000 }); + + void streamFn(model, { messages: [] }, {}); + + expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + model, + { messages: [] }, + expect.objectContaining({ + maxTokens: 128000, + }), + ); + }); + + it("clamps explicit maxTokens to the selected model limit", () => { + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }); + + void streamFn(model, { messages: [] }, { maxTokens: 999999 }); + + expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + model, + { messages: [] }, + expect.objectContaining({ + maxTokens: 128000, + }), + ); + }); + + it("maps xhigh reasoning to max effort for adaptive Opus models", () => { + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const model = makeModel({ id: "claude-opus-4-6", maxTokens: 64000 }); + + void streamFn(model, { messages: [] }, { reasoning: "xhigh" }); + + expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + model, + { messages: [] }, + expect.objectContaining({ + thinkingEnabled: true, + effort: "max", + }), + ); + }); + + it("omits maxTokens when neither the model nor request provide a finite limit", () => { + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const model = makeModel({ id: "claude-sonnet-4-6" }); + + void streamFn(model, { messages: [] }, { maxTokens: Number.NaN }); + + expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + model, + { messages: [] }, + expect.not.objectContaining({ + maxTokens: expect.anything(), + }), + ); + }); +}); + +describe("resolveAnthropicVertexRegionFromBaseUrl", () => { + it("accepts well-formed regional env values", () => { + expect( + resolveAnthropicVertexRegion({ + GOOGLE_CLOUD_LOCATION: "us-east1", + } as NodeJS.ProcessEnv), + ).toBe("us-east1"); + }); + + it("falls back to the default region for malformed env values", () => { + expect( + resolveAnthropicVertexRegion({ + GOOGLE_CLOUD_LOCATION: "us-central1.attacker.example", + } as NodeJS.ProcessEnv), + ).toBe("global"); + }); + + it("parses regional Vertex endpoints", () => { + expect( + resolveAnthropicVertexRegionFromBaseUrl("https://europe-west4-aiplatform.googleapis.com"), + ).toBe("europe-west4"); + }); + + it("treats the global Vertex endpoint as global", () => { + expect(resolveAnthropicVertexRegionFromBaseUrl("https://aiplatform.googleapis.com")).toBe( + "global", + ); + }); +}); + +describe("createAnthropicVertexStreamFnForModel", () => { + beforeEach(() => { + hoisted.anthropicVertexCtorMock.mockClear(); + }); + + it("derives project and region from the model and env", () => { + const streamFn = createAnthropicVertexStreamFnForModel( + { baseUrl: "https://europe-west4-aiplatform.googleapis.com" }, + { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, + ); + + void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {}); + + expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + projectId: "vertex-project", + region: "europe-west4", + baseURL: "https://europe-west4-aiplatform.googleapis.com/v1", + }); + }); + + it("preserves explicit custom provider base URLs", () => { + const streamFn = createAnthropicVertexStreamFnForModel( + { baseUrl: "https://proxy.example.test/custom-root/v1" }, + { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, + ); + + void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {}); + + expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + projectId: "vertex-project", + region: "global", + baseURL: "https://proxy.example.test/custom-root/v1", + }); + }); + + it("adds /v1 for path-prefixed custom provider base URLs", () => { + const streamFn = createAnthropicVertexStreamFnForModel( + { baseUrl: "https://proxy.example.test/custom-root" }, + { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, + ); + + void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {}); + + expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + projectId: "vertex-project", + region: "global", + baseURL: "https://proxy.example.test/custom-root/v1", + }); + }); +}); diff --git a/src/agents/anthropic-vertex-stream.ts b/src/agents/anthropic-vertex-stream.ts new file mode 100644 index 00000000000..de808f5cdd6 --- /dev/null +++ b/src/agents/anthropic-vertex-stream.ts @@ -0,0 +1,137 @@ +import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"; +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { streamAnthropic, type AnthropicOptions, type Model } from "@mariozechner/pi-ai"; +import { + resolveAnthropicVertexClientRegion, + resolveAnthropicVertexProjectId, +} from "./anthropic-vertex-provider.js"; + +type AnthropicVertexEffort = NonNullable; + +function resolveAnthropicVertexMaxTokens(params: { + modelMaxTokens: number | undefined; + requestedMaxTokens: number | undefined; +}): number | undefined { + const modelMax = + typeof params.modelMaxTokens === "number" && + Number.isFinite(params.modelMaxTokens) && + params.modelMaxTokens > 0 + ? Math.floor(params.modelMaxTokens) + : undefined; + const requested = + typeof params.requestedMaxTokens === "number" && + Number.isFinite(params.requestedMaxTokens) && + params.requestedMaxTokens > 0 + ? Math.floor(params.requestedMaxTokens) + : undefined; + + if (modelMax !== undefined && requested !== undefined) { + return Math.min(requested, modelMax); + } + return requested ?? modelMax; +} + +/** + * Create a StreamFn that routes through pi-ai's `streamAnthropic` with an + * injected `AnthropicVertex` client. All streaming, message conversion, and + * event handling is handled by pi-ai — we only supply the GCP-authenticated + * client and map SimpleStreamOptions → AnthropicOptions. + */ +export function createAnthropicVertexStreamFn( + projectId: string | undefined, + region: string, + baseURL?: string, +): StreamFn { + const client = new AnthropicVertex({ + region, + ...(baseURL ? { baseURL } : {}), + ...(projectId ? { projectId } : {}), + }); + + return (model, context, options) => { + const maxTokens = resolveAnthropicVertexMaxTokens({ + modelMaxTokens: model.maxTokens, + requestedMaxTokens: options?.maxTokens, + }); + const opts: AnthropicOptions = { + client: client as unknown as AnthropicOptions["client"], + temperature: options?.temperature, + ...(maxTokens !== undefined ? { maxTokens } : {}), + signal: options?.signal, + cacheRetention: options?.cacheRetention, + sessionId: options?.sessionId, + headers: options?.headers, + onPayload: options?.onPayload, + maxRetryDelayMs: options?.maxRetryDelayMs, + metadata: options?.metadata, + }; + + if (options?.reasoning) { + const isAdaptive = + model.id.includes("opus-4-6") || + model.id.includes("opus-4.6") || + model.id.includes("sonnet-4-6") || + model.id.includes("sonnet-4.6"); + + if (isAdaptive) { + opts.thinkingEnabled = true; + const effortMap: Record = { + minimal: "low", + low: "low", + medium: "medium", + high: "high", + xhigh: model.id.includes("opus-4-6") || model.id.includes("opus-4.6") ? "max" : "high", + }; + opts.effort = effortMap[options.reasoning] ?? "high"; + } else { + opts.thinkingEnabled = true; + const budgets = options.thinkingBudgets; + opts.thinkingBudgetTokens = + (budgets && options.reasoning in budgets + ? budgets[options.reasoning as keyof typeof budgets] + : undefined) ?? 10000; + } + } else { + opts.thinkingEnabled = false; + } + + return streamAnthropic(model as Model<"anthropic-messages">, context, opts); + }; +} + +function resolveAnthropicVertexSdkBaseUrl(baseUrl?: string): string | undefined { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return undefined; + } + + try { + const url = new URL(trimmed); + const normalizedPath = url.pathname.replace(/\/+$/, ""); + if (!normalizedPath || normalizedPath === "") { + url.pathname = "/v1"; + return url.toString().replace(/\/$/, ""); + } + if (!normalizedPath.endsWith("/v1")) { + url.pathname = `${normalizedPath}/v1`; + return url.toString().replace(/\/$/, ""); + } + return trimmed; + } catch { + return trimmed; + } +} + +export function createAnthropicVertexStreamFnForModel( + model: { baseUrl?: string }, + env: NodeJS.ProcessEnv = process.env, +): StreamFn { + return createAnthropicVertexStreamFn( + resolveAnthropicVertexProjectId(env), + resolveAnthropicVertexClientRegion({ + baseUrl: model.baseUrl, + env, + }), + resolveAnthropicVertexSdkBaseUrl(model.baseUrl), + ); +} 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/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts index 960a648675b..96b7aa96317 100644 --- a/src/agents/model-auth-markers.test.ts +++ b/src/agents/model-auth-markers.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; import { + GCP_VERTEX_CREDENTIALS_MARKER, isKnownEnvApiKeyMarker, isNonSecretApiKeyMarker, NON_ENV_SECRETREF_MARKER, @@ -13,6 +14,7 @@ describe("model auth markers", () => { expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(true); expect(isNonSecretApiKeyMarker(resolveOAuthApiKeyMarker("chutes"))).toBe(true); expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true); + expect(isNonSecretApiKeyMarker(GCP_VERTEX_CREDENTIALS_MARKER)).toBe(true); }); it("recognizes known env marker names but not arbitrary all-caps keys", () => { diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts index 37ec67ba2c0..4009630afc8 100644 --- a/src/agents/model-auth-markers.ts +++ b/src/agents/model-auth-markers.ts @@ -6,6 +6,7 @@ export const OAUTH_API_KEY_MARKER_PREFIX = "oauth:"; export const QWEN_OAUTH_MARKER = "qwen-oauth"; export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local"; export const CUSTOM_LOCAL_AUTH_MARKER = "custom-local"; +export const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; export const NON_ENV_SECRETREF_MARKER = "secretref-managed"; // pragma: allowlist secret export const SECRETREF_ENV_HEADER_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret @@ -83,6 +84,7 @@ export function isNonSecretApiKeyMarker( isOAuthApiKeyMarker(trimmed) || trimmed === OLLAMA_LOCAL_AUTH_MARKER || trimmed === CUSTOM_LOCAL_AUTH_MARKER || + trimmed === GCP_VERTEX_CREDENTIALS_MARKER || trimmed === NON_ENV_SECRETREF_MARKER || isAwsSdkAuthMarker(trimmed); if (isKnownMarker) { diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index f9395373024..3213ef7be32 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -506,4 +506,55 @@ describe("getApiKeyForModel", () => { }, ); }); + + it("resolveEnvApiKey('anthropic-vertex') uses the provided env snapshot", async () => { + const resolved = resolveEnvApiKey("anthropic-vertex", { + GOOGLE_CLOUD_PROJECT_ID: "vertex-project", + } as NodeJS.ProcessEnv); + + expect(resolved).toBeNull(); + }); + + it("resolveEnvApiKey('anthropic-vertex') accepts GOOGLE_APPLICATION_CREDENTIALS with project_id", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-")); + const credentialsPath = path.join(tempDir, "adc.json"); + await fs.writeFile(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8"); + + try { + const resolved = resolveEnvApiKey("anthropic-vertex", { + GOOGLE_APPLICATION_CREDENTIALS: credentialsPath, + } as NodeJS.ProcessEnv); + + expect(resolved?.apiKey).toBe("gcp-vertex-credentials"); + expect(resolved?.source).toBe("gcloud adc"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("resolveEnvApiKey('anthropic-vertex') accepts GOOGLE_APPLICATION_CREDENTIALS without a local project field", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-")); + const credentialsPath = path.join(tempDir, "adc.json"); + await fs.writeFile(credentialsPath, "{}", "utf8"); + + try { + const resolved = resolveEnvApiKey("anthropic-vertex", { + GOOGLE_APPLICATION_CREDENTIALS: credentialsPath, + } as NodeJS.ProcessEnv); + + expect(resolved?.apiKey).toBe("gcp-vertex-credentials"); + expect(resolved?.source).toBe("gcloud adc"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("resolveEnvApiKey('anthropic-vertex') accepts explicit metadata auth opt-in", async () => { + const resolved = resolveEnvApiKey("anthropic-vertex", { + ANTHROPIC_VERTEX_USE_GCP_METADATA: "true", + } as NodeJS.ProcessEnv); + + expect(resolved?.apiKey).toBe("gcp-vertex-credentials"); + expect(resolved?.source).toBe("gcloud adc"); + }); }); diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 31fdee5496c..3949a4655a5 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -2,7 +2,11 @@ import { streamSimpleOpenAICompletions, type Model } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; import type { AuthProfileStore } from "./auth-profiles.js"; -import { CUSTOM_LOCAL_AUTH_MARKER, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { + CUSTOM_LOCAL_AUTH_MARKER, + GCP_VERTEX_CREDENTIALS_MARKER, + NON_ENV_SECRETREF_MARKER, +} from "./model-auth-markers.js"; import { applyLocalNoAuthHeaderOverride, hasUsableCustomProviderApiKey, @@ -169,6 +173,24 @@ describe("resolveUsableCustomProviderApiKey", () => { expect(resolved).toBeNull(); }); + it("does not treat the Vertex ADC marker as a usable models.json credential", () => { + const resolved = resolveUsableCustomProviderApiKey({ + cfg: { + models: { + providers: { + "anthropic-vertex": { + baseUrl: "https://us-central1-aiplatform.googleapis.com", + apiKey: GCP_VERTEX_CREDENTIALS_MARKER, + models: [], + }, + }, + }, + }, + provider: "anthropic-vertex", + }); + expect(resolved).toBeNull(); + }); + it("resolves known env marker names from process env for custom providers", () => { const previous = process.env.OPENAI_API_KEY; process.env.OPENAI_API_KEY = "sk-from-env"; // pragma: allowlist secret diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index e494cc71b8c..42665cc4713 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -10,6 +10,7 @@ import { normalizeOptionalSecretInput, normalizeSecretInput, } from "../utils/normalize-secret-input.js"; +import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-provider.js"; import { type AuthProfileStore, ensureAuthProfileStore, @@ -21,6 +22,7 @@ import { import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js"; import { CUSTOM_LOCAL_AUTH_MARKER, + GCP_VERTEX_CREDENTIALS_MARKER, isKnownEnvApiKeyMarker, isNonSecretApiKeyMarker, OLLAMA_LOCAL_AUTH_MARKER, @@ -428,6 +430,16 @@ export function resolveEnvApiKey( } return { apiKey: envKey, source: "gcloud adc" }; } + + if (normalized === "anthropic-vertex") { + // Vertex AI uses GCP credentials (SA JSON or ADC), not API keys. + // Return a sentinel so the model resolver considers this provider available. + if (hasAnthropicVertexAvailableAuth(env)) { + return { apiKey: GCP_VERTEX_CREDENTIALS_MARKER, source: "gcloud adc" }; + } + return null; + } + return null; } diff --git a/src/agents/models-config.e2e-harness.ts b/src/agents/models-config.e2e-harness.ts index 81518ec9aee..bd01edc86be 100644 --- a/src/agents/models-config.e2e-harness.ts +++ b/src/agents/models-config.e2e-harness.ts @@ -112,9 +112,15 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [ "KIMI_API_KEY", "KIMICODE_API_KEY", "GEMINI_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_LOCATION", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_PROJECT_ID", "VENICE_API_KEY", "VLLM_API_KEY", "XIAOMI_API_KEY", + "ANTHROPIC_VERTEX_PROJECT_ID", + "CLOUD_ML_REGION", // Avoid ambient AWS creds unintentionally enabling Bedrock discovery. "AWS_ACCESS_KEY_ID", "AWS_CONFIG_FILE", diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index 5e0f870e476..8906800aa8e 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; @@ -333,6 +334,53 @@ describe("models-config", () => { }); }); }); + + it("fills anthropic-vertex apiKey with the ADC sentinel when models exist", async () => { + await withTempHome(async () => { + const adcDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-")); + const credentialsPath = path.join(adcDir, "application_default_credentials.json"); + await fs.writeFile(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8"); + const previousCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS; + + try { + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + + await ensureOpenClawModelsJson({ + models: { + providers: { + "anthropic-vertex": { + baseUrl: "https://us-central1-aiplatform.googleapis.com", + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + reasoning: true, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 200000, + maxTokens: 64000, + }, + ], + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers["anthropic-vertex"]?.apiKey).toBe("gcp-vertex-credentials"); + } finally { + if (previousCredentials === undefined) { + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + } else { + process.env.GOOGLE_APPLICATION_CREDENTIALS = previousCredentials; + } + await fs.rm(adcDir, { recursive: true, force: true }); + } + }); + }); it("merges providers by default", async () => { await withTempHome(async () => { await writeAgentModelsJson({ diff --git a/src/agents/models-config.providers.anthropic-vertex.test.ts b/src/agents/models-config.providers.anthropic-vertex.test.ts new file mode 100644 index 00000000000..207abe0c5b1 --- /dev/null +++ b/src/agents/models-config.providers.anthropic-vertex.test.ts @@ -0,0 +1,190 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { captureEnv } from "../test-utils/env.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; + +describe("anthropic-vertex implicit provider", () => { + it("offers Claude models when GOOGLE_CLOUD_PROJECT_ID is set", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_CLOUD_PROJECT_ID"]); + process.env.GOOGLE_CLOUD_PROJECT_ID = "vertex-project"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]).toBeUndefined(); + } finally { + envSnapshot.restore(); + } + }); + + it("accepts ADC credentials when the file includes a project_id", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]); + const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-")); + const credentialsPath = join(adcDir, "application_default_credentials.json"); + writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8"); + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + process.env.GOOGLE_CLOUD_LOCATION = "us-east1"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe( + "https://us-east1-aiplatform.googleapis.com", + ); + expect(providers?.["anthropic-vertex"]?.models).toMatchObject([ + { id: "claude-opus-4-6", maxTokens: 128000, contextWindow: 1_000_000 }, + { id: "claude-sonnet-4-6", maxTokens: 128000, contextWindow: 1_000_000 }, + ]); + } finally { + rmSync(adcDir, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); + + it("accepts ADC credentials when the file only includes a quota_project_id", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]); + const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-")); + const credentialsPath = join(adcDir, "application_default_credentials.json"); + writeFileSync(credentialsPath, JSON.stringify({ quota_project_id: "vertex-quota" }), "utf8"); + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + process.env.GOOGLE_CLOUD_LOCATION = "us-east5"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe( + "https://us-east5-aiplatform.googleapis.com", + ); + } finally { + rmSync(adcDir, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); + + it("accepts ADC credentials when project_id is resolved at runtime", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]); + const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-")); + const credentialsPath = join(adcDir, "application_default_credentials.json"); + writeFileSync(credentialsPath, "{}", "utf8"); + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + process.env.GOOGLE_CLOUD_LOCATION = "europe-west4"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe( + "https://europe-west4-aiplatform.googleapis.com", + ); + } finally { + rmSync(adcDir, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); + + it("falls back to the default region when GOOGLE_CLOUD_LOCATION is invalid", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]); + const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-")); + const credentialsPath = join(adcDir, "application_default_credentials.json"); + writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8"); + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + process.env.GOOGLE_CLOUD_LOCATION = "us-central1.attacker.example"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe("https://aiplatform.googleapis.com"); + } finally { + rmSync(adcDir, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); + + it("uses the Vertex global endpoint when GOOGLE_CLOUD_LOCATION=global", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]); + const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-")); + const credentialsPath = join(adcDir, "application_default_credentials.json"); + writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8"); + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + process.env.GOOGLE_CLOUD_LOCATION = "global"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe("https://aiplatform.googleapis.com"); + } finally { + rmSync(adcDir, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); + + it("accepts explicit metadata auth opt-in without local credential files", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["ANTHROPIC_VERTEX_USE_GCP_METADATA", "GOOGLE_CLOUD_LOCATION"]); + process.env.ANTHROPIC_VERTEX_USE_GCP_METADATA = "true"; + process.env.GOOGLE_CLOUD_LOCATION = "us-east5"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe( + "https://us-east5-aiplatform.googleapis.com", + ); + } finally { + envSnapshot.restore(); + } + }); + + it("merges the bundled catalog into explicit anthropic-vertex provider overrides", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]); + const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-")); + const credentialsPath = join(adcDir, "application_default_credentials.json"); + writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8"); + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + process.env.GOOGLE_CLOUD_LOCATION = "us-east5"; + + try { + const providers = await resolveImplicitProvidersForTest({ + agentDir, + config: { + models: { + providers: { + "anthropic-vertex": { + baseUrl: "https://europe-west4-aiplatform.googleapis.com", + headers: { "x-test-header": "1" }, + }, + }, + }, + } as unknown as OpenClawConfig, + }); + + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe( + "https://europe-west4-aiplatform.googleapis.com", + ); + expect(providers?.["anthropic-vertex"]?.headers).toEqual({ "x-test-header": "1" }); + expect(providers?.["anthropic-vertex"]?.models?.map((model) => model.id)).toEqual([ + "claude-opus-4-6", + "claude-sonnet-4-6", + ]); + } finally { + rmSync(adcDir, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); + + it("does not accept generic Kubernetes env without a GCP ADC signal", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["KUBERNETES_SERVICE_HOST", "GOOGLE_CLOUD_LOCATION"]); + process.env.KUBERNETES_SERVICE_HOST = "10.0.0.1"; + process.env.GOOGLE_CLOUD_LOCATION = "us-east5"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]).toBeUndefined(); + } finally { + envSnapshot.restore(); + } + }); +}); diff --git a/src/agents/models-config.providers.static.ts b/src/agents/models-config.providers.static.ts index 71184e12286..dea2c4e6f2f 100644 --- a/src/agents/models-config.providers.static.ts +++ b/src/agents/models-config.providers.static.ts @@ -1,3 +1,7 @@ +export { + ANTHROPIC_VERTEX_DEFAULT_MODEL_ID, + buildAnthropicVertexProvider, +} from "../../extensions/anthropic-vertex/provider-catalog.js"; export { buildBytePlusCodingProvider, buildBytePlusProvider, diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 57f10206984..f4f6172dc09 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1,3 +1,4 @@ +import { buildAnthropicVertexProvider } from "../../extensions/anthropic-vertex/provider-catalog.js"; import { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID, @@ -7,6 +8,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; import { isRecord } from "../utils.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; +import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-provider.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js"; @@ -552,7 +554,10 @@ export function normalizeProviders(params: { mutated = true; normalizedProvider = { ...normalizedProvider, apiKey }; } else { - const fromEnv = resolveEnvApiKeyVarName(normalizedKey, env); + const fromEnv = + normalizedKey === "anthropic-vertex" + ? resolveEnvApiKey(normalizedKey, env)?.apiKey + : resolveEnvApiKeyVarName(normalizedKey, env); const apiKey = fromEnv ?? profileApiKey?.apiKey; if (apiKey?.trim()) { if (profileApiKey && profileApiKey.source !== "plaintext") { @@ -812,9 +817,34 @@ export async function resolveImplicitProviders( : implicitBedrock; } + const implicitAnthropicVertex = resolveImplicitAnthropicVertexProvider({ env }); + if (implicitAnthropicVertex) { + const existing = providers["anthropic-vertex"]; + providers["anthropic-vertex"] = existing + ? { + ...implicitAnthropicVertex, + ...existing, + models: + Array.isArray(existing.models) && existing.models.length > 0 + ? existing.models + : implicitAnthropicVertex.models, + } + : implicitAnthropicVertex; + } + return providers; } +export function resolveImplicitAnthropicVertexProvider(params: { + env?: NodeJS.ProcessEnv; +}): ProviderConfig | null { + const env = params.env ?? process.env; + if (!hasAnthropicVertexAvailableAuth(env)) { + return null; + } + + return buildAnthropicVertexProvider({ env }); +} export async function resolveImplicitBedrockProvider(params: { agentDir: string; config?: OpenClawConfig; 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 f4efb7fdcfd..f4bc91daf31 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -36,6 +36,7 @@ import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { resolveOpenClawAgentDir } from "../../agent-paths.js"; import { resolveSessionAgentIds } from "../../agent-scope.js"; import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js"; +import { createAnthropicVertexStreamFnForModel } from "../../anthropic-vertex-stream.js"; import { analyzeBootstrapBudget, buildBootstrapPromptWarning, @@ -97,6 +98,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"; @@ -649,6 +651,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; @@ -797,6 +993,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] ?? "")) { @@ -1989,6 +2222,10 @@ export async function runEmbeddedAttempt( log.warn(`[ws-stream] no API key for provider=${params.provider}; using HTTP transport`); activeSession.agent.streamFn = streamSimple; } + } else if (params.model.provider === "anthropic-vertex") { + // Anthropic Vertex AI: inject AnthropicVertex client into pi-ai's + // streamAnthropic for GCP IAM auth instead of Anthropic API keys. + activeSession.agent.streamFn = createAnthropicVertexStreamFnForModel(params.model); } else { // Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai. activeSession.agent.streamFn = streamSimple; @@ -2125,6 +2362,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/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 1712f6f810e..09f19468776 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -69,6 +69,18 @@ describe("resolveProviderCapabilities", () => { geminiThoughtSignatureModelHints: [], dropThinkingBlockModelHints: ["claude"], }); + expect(resolveProviderCapabilities("anthropic-vertex")).toEqual({ + anthropicToolSchemaMode: "native", + anthropicToolChoiceMode: "native", + providerFamily: "anthropic", + preserveAnthropicThinkingSignatures: true, + openAiCompatTurnValidation: true, + geminiThoughtSignatureSanitization: false, + transcriptToolCallIdMode: "default", + transcriptToolCallIdModelHints: [], + geminiThoughtSignatureModelHints: [], + dropThinkingBlockModelHints: ["claude"], + }); expect(resolveProviderCapabilities("amazon-bedrock")).toEqual({ anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", @@ -136,6 +148,7 @@ describe("resolveProviderCapabilities", () => { it("tracks provider families and model-specific transcript quirks in the registry", () => { expect(isOpenAiProviderFamily("openai")).toBe(true); + expect(isAnthropicProviderFamily("anthropic-vertex")).toBe(true); expect(isAnthropicProviderFamily("amazon-bedrock")).toBe(true); expect( shouldDropThinkingBlocksForModel({ @@ -143,6 +156,12 @@ describe("resolveProviderCapabilities", () => { modelId: "claude-opus-4-6", }), ).toBe(true); + expect( + shouldDropThinkingBlocksForModel({ + provider: "anthropic-vertex", + modelId: "claude-sonnet-4-6", + }), + ).toBe(true); expect( shouldDropThinkingBlocksForModel({ provider: "amazon-bedrock", diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 2fe11666766..c52be686387 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -35,6 +35,10 @@ const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { }; const CORE_PROVIDER_CAPABILITIES: Record> = { + "anthropic-vertex": { + providerFamily: "anthropic", + dropThinkingBlockModelHints: ["claude"], + }, "amazon-bedrock": { providerFamily: "anthropic", dropThinkingBlockModelHints: ["claude"], 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/bundled-web-search-registry.ts b/src/bundled-web-search-registry.ts new file mode 100644 index 00000000000..689f0b7d614 --- /dev/null +++ b/src/bundled-web-search-registry.ts @@ -0,0 +1,61 @@ +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; + register: (api: OpenClawPluginApi) => void; +}; + +export const bundledWebSearchPluginRegistrations: ReadonlyArray<{ + readonly plugin: RegistrablePlugin; + credentialValue: unknown; +}> = [ + { + get plugin() { + return bravePlugin; + }, + credentialValue: "BSA-test", + }, + { + get plugin() { + return firecrawlPlugin; + }, + credentialValue: "fc-test", + }, + { + get plugin() { + return googlePlugin; + }, + credentialValue: "AIza-test", + }, + { + get plugin() { + return moonshotPlugin; + }, + credentialValue: "sk-test", + }, + { + get plugin() { + return perplexityPlugin; + }, + credentialValue: "pplx-test", + }, + { + get plugin() { + return tavilyPlugin; + }, + credentialValue: "tvly-test", + }, + { + get plugin() { + return xaiPlugin; + }, + credentialValue: "xai-test", + }, +]; diff --git a/src/cron/isolated-agent/run.sandbox-config-preserved.test.ts b/src/cron/isolated-agent/run.sandbox-config-preserved.test.ts index d953185c369..cadde9700a4 100644 --- a/src/cron/isolated-agent/run.sandbox-config-preserved.test.ts +++ b/src/cron/isolated-agent/run.sandbox-config-preserved.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { clearFastTestEnv, loadRunCronIsolatedAgentTurn, @@ -8,11 +8,7 @@ import { runWithModelFallbackMock, } from "./run.test-harness.js"; -type RunModule = typeof import("./run.js"); -type SandboxConfigModule = typeof import("../../agents/sandbox/config.js"); - -let runCronIsolatedAgentTurn: RunModule["runCronIsolatedAgentTurn"]; -let resolveSandboxConfigForAgent: SandboxConfigModule["resolveSandboxConfigForAgent"]; +const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); function makeJob(overrides?: Record) { return { @@ -85,10 +81,7 @@ function expectDefaultSandboxPreserved( describe("runCronIsolatedAgentTurn sandbox config preserved", () => { let previousFastTestEnv: string | undefined; - beforeEach(async () => { - vi.resetModules(); - runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); - ({ resolveSandboxConfigForAgent } = await import("../../agents/sandbox/config.js")); + beforeEach(() => { previousFastTestEnv = clearFastTestEnv(); resetRunCronIsolatedAgentTurnHarness(); }); @@ -132,6 +125,7 @@ describe("runCronIsolatedAgentTurn sandbox config preserved", () => { expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1); const runCfg = runWithModelFallbackMock.mock.calls[0]?.[0]?.cfg; + const { resolveSandboxConfigForAgent } = await import("../../agents/sandbox/config.js"); const resolvedSandbox = resolveSandboxConfigForAgent(runCfg, "specialist"); expectDefaultSandboxPreserved(runCfg); diff --git a/src/image-generation/providers/openai.ts b/src/image-generation/providers/openai.ts index 7bce3854ab3..0913025102a 100644 --- a/src/image-generation/providers/openai.ts +++ b/src/image-generation/providers/openai.ts @@ -58,6 +58,13 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu throw new Error("OpenAI API key missing"); } + const controller = new AbortController(); + const timeoutMs = req.timeoutMs; + const timeout = + typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? setTimeout(() => controller.abort(), timeoutMs) + : undefined; + const response = await fetch(`${resolveOpenAIBaseUrl(req.cfg)}/images/generations`, { method: "POST", headers: { @@ -70,6 +77,9 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu n: req.count ?? 1, size: req.size ?? DEFAULT_SIZE, }), + signal: controller.signal, + }).finally(() => { + clearTimeout(timeout); }); if (!response.ok) { diff --git a/src/image-generation/types.ts b/src/image-generation/types.ts index 123d5d98e6c..8e1a8fa0136 100644 --- a/src/image-generation/types.ts +++ b/src/image-generation/types.ts @@ -25,6 +25,7 @@ export type ImageGenerationRequest = { cfg: OpenClawConfig; agentDir?: string; authStore?: AuthProfileStore; + timeoutMs?: number; count?: number; size?: string; aspectRatio?: string; 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/media-understanding/providers/image.test.ts b/src/media-understanding/providers/image.test.ts index 9044d8ba83d..7427cc84d34 100644 --- a/src/media-understanding/providers/image.test.ts +++ b/src/media-understanding/providers/image.test.ts @@ -1,58 +1,72 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const completeMock = vi.fn(); -const minimaxUnderstandImageMock = vi.fn(); -const ensureOpenClawModelsJsonMock = vi.fn(async () => {}); -const getApiKeyForModelMock = vi.fn(async () => ({ - apiKey: "oauth-test", // pragma: allowlist secret - source: "test", - mode: "oauth", +const hoisted = vi.hoisted(() => ({ + completeMock: vi.fn(), + minimaxUnderstandImageMock: vi.fn(), + ensureOpenClawModelsJsonMock: vi.fn(async () => {}), + getApiKeyForModelMock: vi.fn(async () => ({ + apiKey: "oauth-test", // pragma: allowlist secret + source: "test", + mode: "oauth", + })), + resolveApiKeyForProviderMock: vi.fn(async () => ({ + apiKey: "oauth-test", // pragma: allowlist secret + source: "test", + mode: "oauth", + })), + requireApiKeyMock: vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""), + setRuntimeApiKeyMock: vi.fn(), + discoverModelsMock: vi.fn(), })); -const resolveApiKeyForProviderMock = vi.fn(async () => ({ - apiKey: "oauth-test", // pragma: allowlist secret - source: "test", - mode: "oauth", -})); -const requireApiKeyMock = vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""); -const setRuntimeApiKeyMock = vi.fn(); -const discoverModelsMock = vi.fn(); -type ImageModule = typeof import("./image.js"); +const { + completeMock, + minimaxUnderstandImageMock, + ensureOpenClawModelsJsonMock, + getApiKeyForModelMock, + resolveApiKeyForProviderMock, + requireApiKeyMock, + setRuntimeApiKeyMock, + discoverModelsMock, +} = hoisted; -let describeImageWithModel: ImageModule["describeImageWithModel"]; +vi.mock("@mariozechner/pi-ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + complete: completeMock, + }; +}); + +vi.mock("../../agents/minimax-vlm.js", () => ({ + isMinimaxVlmProvider: (provider: string) => + provider === "minimax" || provider === "minimax-portal", + isMinimaxVlmModel: (provider: string, modelId: string) => + (provider === "minimax" || provider === "minimax-portal") && modelId === "MiniMax-VL-01", + minimaxUnderstandImage: minimaxUnderstandImageMock, +})); + +vi.mock("../../agents/models-config.js", () => ({ + ensureOpenClawModelsJson: ensureOpenClawModelsJsonMock, +})); + +vi.mock("../../agents/model-auth.js", () => ({ + getApiKeyForModel: getApiKeyForModelMock, + resolveApiKeyForProvider: resolveApiKeyForProviderMock, + requireApiKey: requireApiKeyMock, +})); + +vi.mock("../../agents/pi-model-discovery-runtime.js", () => ({ + discoverAuthStorage: () => ({ + setRuntimeApiKey: setRuntimeApiKeyMock, + }), + discoverModels: discoverModelsMock, +})); + +const { describeImageWithModel } = await import("./image.js"); describe("describeImageWithModel", () => { - beforeEach(async () => { - vi.resetModules(); + beforeEach(() => { vi.clearAllMocks(); - vi.doMock("@mariozechner/pi-ai", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - complete: completeMock, - }; - }); - vi.doMock("../../agents/minimax-vlm.js", () => ({ - isMinimaxVlmProvider: (provider: string) => - provider === "minimax" || provider === "minimax-portal", - isMinimaxVlmModel: (provider: string, modelId: string) => - (provider === "minimax" || provider === "minimax-portal") && modelId === "MiniMax-VL-01", - minimaxUnderstandImage: minimaxUnderstandImageMock, - })); - vi.doMock("../../agents/models-config.js", () => ({ - ensureOpenClawModelsJson: ensureOpenClawModelsJsonMock, - })); - vi.doMock("../../agents/model-auth.js", () => ({ - getApiKeyForModel: getApiKeyForModelMock, - resolveApiKeyForProvider: resolveApiKeyForProviderMock, - requireApiKey: requireApiKeyMock, - })); - vi.doMock("../../agents/pi-model-discovery-runtime.js", () => ({ - discoverAuthStorage: () => ({ - setRuntimeApiKey: setRuntimeApiKeyMock, - }), - discoverModels: discoverModelsMock, - })); - ({ describeImageWithModel } = await import("./image.js")); minimaxUnderstandImageMock.mockResolvedValue("portal ok"); discoverModelsMock.mockReturnValue({ find: vi.fn(() => ({ diff --git a/src/media-understanding/providers/image.ts b/src/media-understanding/providers/image.ts index 9d7dc67949b..3702f0f20f0 100644 --- a/src/media-understanding/providers/image.ts +++ b/src/media-understanding/providers/image.ts @@ -188,9 +188,19 @@ export async function describeImagesWithModel( } const context = buildImageContext(prompt, params.images); + const controller = new AbortController(); + const timeout = + typeof params.timeoutMs === "number" && + Number.isFinite(params.timeoutMs) && + params.timeoutMs > 0 + ? setTimeout(() => controller.abort(), params.timeoutMs) + : undefined; const message = await complete(model, context, { apiKey, maxTokens: resolveImageToolMaxTokens(model.maxTokens, params.maxTokens ?? 512), + signal: controller.signal, + }).finally(() => { + clearTimeout(timeout); }); const text = coerceImageAssistantText({ message, 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..de299a2cc6a 100644 --- a/src/node-host/invoke.sanitize-env.test.ts +++ b/src/node-host/invoke.sanitize-env.test.ts @@ -3,6 +3,19 @@ import { withEnv } from "../test-utils/env.js"; import { decodeCapturedOutputBuffer, parseWindowsCodePage, sanitizeEnv } from "./invoke.js"; import { buildNodeInvokeResultParams } from "./runner.js"; +function getEnvValueCaseInsensitive( + env: Record, + expectedKey: string, +): string | undefined { + const direct = env[expectedKey]; + if (direct !== undefined) { + return direct; + } + const upper = expectedKey.toUpperCase(); + const actualKey = Object.keys(env).find((key) => key.toUpperCase() === upper); + return actualKey ? env[actualKey] : undefined; +} + describe("node-host sanitizeEnv", () => { it("ignores PATH overrides", () => { withEnv({ PATH: "/usr/bin" }, () => { @@ -51,6 +64,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(getEnvValueCaseInsensitive(env, "ProgramFiles(x86)")).toBe("C:\\Program Files (x86)"); + }); + }); }); describe("node-host output decoding", () => { diff --git a/src/plugin-sdk/matrix-runtime-heavy.ts b/src/plugin-sdk/matrix-runtime-heavy.ts new file mode 100644 index 00000000000..cc153f83e4b --- /dev/null +++ b/src/plugin-sdk/matrix-runtime-heavy.ts @@ -0,0 +1,7 @@ +// Matrix runtime helpers that are needed internally by the bundled extension +// but are too heavy for the light external runtime-api surface. + +export { ensureConfiguredAcpBindingReady } from "../acp/persistent-bindings.lifecycle.js"; +export { resolveConfiguredAcpBindingRecord } from "../acp/persistent-bindings.resolve.js"; +export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js"; +export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; diff --git a/src/plugin-sdk/matrix-runtime-shared.ts b/src/plugin-sdk/matrix-runtime-shared.ts new file mode 100644 index 00000000000..862a1445dea --- /dev/null +++ b/src/plugin-sdk/matrix-runtime-shared.ts @@ -0,0 +1,11 @@ +// Narrow shared Matrix runtime exports for light runtime-api consumers. + +export type { + ChannelDirectoryEntry, + ChannelMessageActionContext, +} from "../channels/plugins/types.js"; +export type { OpenClawConfig } from "../config/config.js"; +export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js"; +export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; +export type { RuntimeEnv } from "../runtime.js"; +export type { WizardPrompter } from "../wizard/prompts.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 22bba927e64..012dc4e6b10 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -27,8 +27,6 @@ export { patchAllowlistUsersInConfigEntries, summarizeMapping, } from "../channels/allowlists/resolve-utils.js"; -export { ensureConfiguredAcpBindingReady } from "../acp/persistent-bindings.lifecycle.js"; -export { resolveConfiguredAcpBindingRecord } from "../acp/persistent-bindings.resolve.js"; export { resolveControlCommandGate } from "../channels/command-gating.js"; export type { NormalizedLocation } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js"; @@ -112,7 +110,6 @@ export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; -export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js"; export { getSessionBindingService, registerSessionBindingAdapter, @@ -150,7 +147,6 @@ export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store. export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { runPluginCommandWithTimeout } from "./run-command.js"; export { createLoggerBackedRuntime, resolveRuntimeEnv } from "./runtime.js"; -export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; export { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index da71fc796aa..e38c02138bb 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -41,6 +41,7 @@ export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, resolveCloudflareAiGatewayBaseUrl, } from "../agents/cloudflare-ai-gateway.js"; +export { resolveAnthropicVertexRegion } from "../agents/anthropic-vertex-provider.js"; export { discoverHuggingfaceModels, HUGGINGFACE_BASE_URL, diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index 47d3543dd33..f9e4c411e6a 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -38,7 +38,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { "extensions/matrix/runtime-api.ts": [ 'export * from "./src/auth-precedence.js";', 'export * from "./helper-api.js";', - 'export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime";', + 'export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";', 'export { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey } from "./thread-bindings-runtime.js";', 'export { writeJsonFileAtomically } from "../../src/plugin-sdk/json-store.js";', 'export type { ChannelDirectoryEntry, ChannelMessageActionContext, OpenClawConfig, PluginRuntime, RuntimeLogger, RuntimeEnv, WizardPrompter } from "../../src/plugin-sdk/matrix.js";', diff --git a/src/plugin-sdk/ssrf-runtime.ts b/src/plugin-sdk/ssrf-runtime.ts new file mode 100644 index 00000000000..a05c7e8ad89 --- /dev/null +++ b/src/plugin-sdk/ssrf-runtime.ts @@ -0,0 +1,14 @@ +// Narrow SSRF helpers for extensions that need pinned-dispatcher and policy +// utilities without loading the full infra-runtime surface. + +export { + closeDispatcher, + createPinnedDispatcher, + resolvePinnedHostnameWithPolicy, + type LookupFn, + type SsrFPolicy, +} from "../infra/net/ssrf.js"; +export { + assertHttpUrlTargetsPrivateNetwork, + ssrfPolicyFromAllowPrivateNetwork, +} from "./ssrf-policy.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index a5fd1d9dc23..b6e3abcd647 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -36,6 +36,7 @@ import type { import * as directoryRuntimeSdk from "openclaw/plugin-sdk/directory-runtime"; import * as infraRuntimeSdk from "openclaw/plugin-sdk/infra-runtime"; import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime"; +import * as matrixRuntimeSharedSdk from "openclaw/plugin-sdk/matrix-runtime-shared"; import * as mediaRuntimeSdk from "openclaw/plugin-sdk/media-runtime"; import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; import * as providerAuthSdk from "openclaw/plugin-sdk/provider-auth"; @@ -50,7 +51,9 @@ import * as sandboxSdk from "openclaw/plugin-sdk/sandbox"; import * as secretInputSdk from "openclaw/plugin-sdk/secret-input"; import * as selfHostedProviderSetupSdk from "openclaw/plugin-sdk/self-hosted-provider-setup"; import * as setupSdk from "openclaw/plugin-sdk/setup"; +import * as ssrfRuntimeSdk from "openclaw/plugin-sdk/ssrf-runtime"; import * as testingSdk from "openclaw/plugin-sdk/testing"; +import * as threadBindingsRuntimeSdk from "openclaw/plugin-sdk/thread-bindings-runtime"; import * as webhookIngressSdk from "openclaw/plugin-sdk/webhook-ingress"; import { describe, expect, expectTypeOf, it } from "vitest"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; @@ -523,6 +526,22 @@ describe("plugin-sdk subpath exports", () => { expect(typeof conversationRuntimeSdk.createTopLevelChannelReplyToModeResolver).toBe("function"); }); + it("exports narrow binding lifecycle helpers from the dedicated subpath", () => { + expect(typeof threadBindingsRuntimeSdk.resolveThreadBindingLifecycle).toBe("function"); + }); + + it("exports narrow matrix runtime helpers from the dedicated subpath", () => { + expect(typeof matrixRuntimeSharedSdk.formatZonedTimestamp).toBe("function"); + }); + + it("exports narrow ssrf helpers from the dedicated subpath", () => { + expect(typeof ssrfRuntimeSdk.closeDispatcher).toBe("function"); + expect(typeof ssrfRuntimeSdk.createPinnedDispatcher).toBe("function"); + expect(typeof ssrfRuntimeSdk.resolvePinnedHostnameWithPolicy).toBe("function"); + expect(typeof ssrfRuntimeSdk.assertHttpUrlTargetsPrivateNetwork).toBe("function"); + expect(typeof ssrfRuntimeSdk.ssrfPolicyFromAllowPrivateNetwork).toBe("function"); + }); + it("exports provider setup helpers from the dedicated subpath", () => { expect(typeof providerSetupSdk.buildVllmProvider).toBe("function"); expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function"); diff --git a/src/plugin-sdk/thread-bindings-runtime.ts b/src/plugin-sdk/thread-bindings-runtime.ts new file mode 100644 index 00000000000..007c46465be --- /dev/null +++ b/src/plugin-sdk/thread-bindings-runtime.ts @@ -0,0 +1,9 @@ +// Narrow thread-binding lifecycle helpers for extensions that need binding +// expiry and session-binding record types without loading the full +// conversation-runtime surface. + +export { resolveThreadBindingLifecycle } from "../channels/thread-bindings-policy.js"; +export type { + BindingTargetKind, + SessionBindingRecord, +} from "../infra/outbound/session-binding-service.js"; diff --git a/src/plugins/bundled-web-search-registry.ts b/src/plugins/bundled-web-search-registry.ts deleted file mode 100644 index 15c04dd2935..00000000000 --- a/src/plugins/bundled-web-search-registry.ts +++ /dev/null @@ -1,26 +0,0 @@ -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"; - -type RegistrablePlugin = { - id: string; - register: (api: OpenClawPluginApi) => void; -}; - -export const bundledWebSearchPluginRegistrations: ReadonlyArray<{ - plugin: RegistrablePlugin; - credentialValue: unknown; -}> = [ - { plugin: bravePlugin, credentialValue: "BSA-test" }, - { plugin: firecrawlPlugin, credentialValue: "fc-test" }, - { plugin: googlePlugin, credentialValue: "AIza-test" }, - { plugin: moonshotPlugin, credentialValue: "sk-test" }, - { plugin: perplexityPlugin, credentialValue: "pplx-test" }, - { plugin: tavilyPlugin, credentialValue: "tvly-test" }, - { plugin: xaiPlugin, credentialValue: "xai-test" }, -]; diff --git a/src/plugins/bundled-web-search.ts b/src/plugins/bundled-web-search.ts index 5b709aa00ee..3aa01274da6 100644 --- a/src/plugins/bundled-web-search.ts +++ b/src/plugins/bundled-web-search.ts @@ -1,26 +1,61 @@ -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"; import type { PluginWebSearchProviderEntry } from "./types.js"; -export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = bundledWebSearchPluginRegistrations - .map((entry) => entry.plugin.id) - .toSorted((left, right) => left.localeCompare(right)); - -const bundledWebSearchPluginIdSet = new Set(BUNDLED_WEB_SEARCH_PLUGIN_IDS); - type BundledWebSearchProviderEntry = PluginWebSearchProviderEntry & { pluginId: string }; +type BundledWebSearchPluginRegistration = (typeof bundledWebSearchPluginRegistrations)[number]; let bundledWebSearchProvidersCache: BundledWebSearchProviderEntry[] | null = null; +let bundledWebSearchPluginIdsCache: string[] | null = null; + +function resolveBundledWebSearchPlugin( + entry: BundledWebSearchPluginRegistration, +): BundledWebSearchPluginRegistration["plugin"] | null { + try { + return entry.plugin; + } catch { + return null; + } +} + +function listBundledWebSearchPluginRegistrations() { + return bundledWebSearchPluginRegistrations + .map((entry) => { + const plugin = resolveBundledWebSearchPlugin(entry); + return plugin ? { ...entry, plugin } : null; + }) + .filter( + ( + entry, + ): entry is BundledWebSearchPluginRegistration & { + plugin: BundledWebSearchPluginRegistration["plugin"]; + } => Boolean(entry), + ); +} + +function loadBundledWebSearchPluginIds(): string[] { + if (!bundledWebSearchPluginIdsCache) { + bundledWebSearchPluginIdsCache = listBundledWebSearchPluginRegistrations() + .map(({ plugin }) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); + } + return bundledWebSearchPluginIdsCache; +} + +export function listBundledWebSearchPluginIds(): string[] { + return loadBundledWebSearchPluginIds(); +} function loadBundledWebSearchProviders(): BundledWebSearchProviderEntry[] { if (!bundledWebSearchProvidersCache) { - bundledWebSearchProvidersCache = bundledWebSearchPluginRegistrations.flatMap(({ plugin }) => - capturePluginRegistration(plugin).webSearchProviders.map((provider) => ({ - ...provider, - pluginId: plugin.id, - })), + bundledWebSearchProvidersCache = listBundledWebSearchPluginRegistrations().flatMap( + ({ plugin }) => + capturePluginRegistration(plugin).webSearchProviders.map((provider) => ({ + ...provider, + pluginId: plugin.id, + })), ); } return bundledWebSearchProvidersCache; @@ -36,6 +71,7 @@ export function resolveBundledWebSearchPluginIds(params: { workspaceDir: params.workspaceDir, env: params.env, }); + const bundledWebSearchPluginIdSet = new Set(loadBundledWebSearchPluginIds()); return registry.plugins .filter((plugin) => plugin.origin === "bundled" && bundledWebSearchPluginIdSet.has(plugin.id)) .map((plugin) => plugin.id) 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 { diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 8794567f98b..45f94f235dd 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import { - BUNDLED_WEB_SEARCH_PLUGIN_IDS, + listBundledWebSearchPluginIds, resolveBundledWebSearchPluginId, } from "../plugins/bundled-web-search.js"; import type { @@ -82,7 +82,7 @@ function hasCustomWebSearchPluginRisk(config: OpenClawConfig): boolean { return true; } - const bundledPluginIds = new Set(BUNDLED_WEB_SEARCH_PLUGIN_IDS); + const bundledPluginIds = new Set(listBundledWebSearchPluginIds()); const hasNonBundledPluginId = (pluginId: string) => !bundledPluginIds.has(pluginId.trim()); if (Array.isArray(plugins.allow) && plugins.allow.some(hasNonBundledPluginId)) { return true; 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); } }); diff --git a/test/fixtures/test-timings.unit.json b/test/fixtures/test-timings.unit.json index cdb2505d881..bea7e0d1178 100644 --- a/test/fixtures/test-timings.unit.json +++ b/test/fixtures/test-timings.unit.json @@ -1,227 +1,1031 @@ { "config": "vitest.unit.config.ts", - "generatedAt": "2026-03-18T17:10:00.000Z", + "generatedAt": "2026-03-20T22:27:39.886Z", "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 + }, + "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 } } }