diff --git a/src/agents/auth-profiles.markauthprofilefailure.test.ts b/src/agents/auth-profiles.markauthprofilefailure.test.ts index ded22634c1a..418bea977d3 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.test.ts @@ -14,10 +14,9 @@ vi.mock("../plugins/provider-runtime.js", () => ({ import { clearRuntimeAuthProfileStoreSnapshots, - calculateAuthProfileCooldownMs, ensureAuthProfileStore, - markAuthProfileFailure, -} from "./auth-profiles.js"; +} from "./auth-profiles/store.js"; +import { calculateAuthProfileCooldownMs, markAuthProfileFailure } from "./auth-profiles/usage.js"; type AuthProfileStore = ReturnType; diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index b8807f097ab..6e5694a5b88 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -1,6 +1,5 @@ import path from "node:path"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { Type } from "@sinclair/typebox"; import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS, resolveExecApprovalAllowedDecisions, @@ -44,6 +43,8 @@ import { import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js"; +export { execSchema } from "./bash-tools.schemas.js"; + const SMKX = "\x1b[?1h"; const RMKX = "\x1b[?1l"; @@ -123,54 +124,6 @@ export const DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS = DEFAULT_APPROVAL_TIMEOUT_MS + const DEFAULT_APPROVAL_RUNNING_NOTICE_MS = 10_000; const APPROVAL_SLUG_LENGTH = 8; -export const execSchema = Type.Object({ - command: Type.String({ description: "Shell command to execute" }), - workdir: Type.Optional(Type.String({ description: "Working directory (defaults to cwd)" })), - env: Type.Optional(Type.Record(Type.String(), Type.String())), - yieldMs: Type.Optional( - Type.Number({ - description: "Milliseconds to wait before backgrounding (default 10000)", - }), - ), - background: Type.Optional(Type.Boolean({ description: "Run in background immediately" })), - timeout: Type.Optional( - Type.Number({ - description: "Timeout in seconds (optional, kills process on expiry)", - }), - ), - pty: Type.Optional( - Type.Boolean({ - description: - "Run in a pseudo-terminal (PTY) when available (TTY-required CLIs, coding agents)", - }), - ), - elevated: Type.Optional( - Type.Boolean({ - description: "Run on the host with elevated permissions (if allowed)", - }), - ), - host: Type.Optional( - Type.String({ - description: "Exec host/target (auto|sandbox|gateway|node).", - }), - ), - security: Type.Optional( - Type.String({ - description: "Exec security mode (deny|allowlist|full).", - }), - ), - ask: Type.Optional( - Type.String({ - description: "Exec ask mode (off|on-miss|always).", - }), - ), - node: Type.Optional( - Type.String({ - description: "Node id/name for host=node.", - }), - ), -}); - export type ExecProcessFailureKind = | "shell-command-not-found" | "shell-not-executable" diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 25d0ac32253..5082b421776 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -1,5 +1,4 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { Type } from "@sinclair/typebox"; import { formatDurationCompact } from "../infra/format-time/format-duration.ts"; import { getDiagnosticSessionState } from "../logging/diagnostic-session-state.js"; import { killProcessTree } from "../process/kill-tree.js"; @@ -17,6 +16,7 @@ import { } from "./bash-process-registry.js"; import { describeProcessTool } from "./bash-tools.descriptions.js"; import { handleProcessSendKeys, type WritableStdin } from "./bash-tools.process-send-keys.js"; +import { processSchema } from "./bash-tools.schemas.js"; import { deriveSessionName, pad, sliceLogLines, truncateMiddle } from "./bash-tools.shared.js"; import { recordCommandPoll, resetCommandPollCount } from "./command-poll-backoff.js"; import { encodePaste } from "./pty-keys.js"; @@ -49,28 +49,6 @@ function defaultTailNote(totalLines: number, usingDefaultTail: boolean) { return `\n\n[showing last ${DEFAULT_LOG_TAIL_LINES} of ${totalLines} lines; pass offset/limit to page]`; } -const processSchema = Type.Object({ - action: Type.String({ description: "Process action" }), - sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })), - data: Type.Optional(Type.String({ description: "Data to write for write" })), - keys: Type.Optional( - Type.Array(Type.String(), { description: "Key tokens to send for send-keys" }), - ), - hex: Type.Optional(Type.Array(Type.String(), { description: "Hex bytes to send for send-keys" })), - literal: Type.Optional(Type.String({ description: "Literal string for send-keys" })), - text: Type.Optional(Type.String({ description: "Text to paste for paste" })), - bracketed: Type.Optional(Type.Boolean({ description: "Wrap paste in bracketed mode" })), - eof: Type.Optional(Type.Boolean({ description: "Close stdin after write" })), - offset: Type.Optional(Type.Number({ description: "Log offset" })), - limit: Type.Optional(Type.Number({ description: "Log length" })), - timeout: Type.Optional( - Type.Number({ - description: "For poll: wait up to this many milliseconds before returning", - minimum: 0, - }), - ), -}); - const MAX_POLL_WAIT_MS = 120_000; function resolvePollWaitMs(value: unknown) { diff --git a/src/agents/bash-tools.schemas.ts b/src/agents/bash-tools.schemas.ts new file mode 100644 index 00000000000..785e3c02280 --- /dev/null +++ b/src/agents/bash-tools.schemas.ts @@ -0,0 +1,71 @@ +import { Type } from "@sinclair/typebox"; + +export const execSchema = Type.Object({ + command: Type.String({ description: "Shell command to execute" }), + workdir: Type.Optional(Type.String({ description: "Working directory (defaults to cwd)" })), + env: Type.Optional(Type.Record(Type.String(), Type.String())), + yieldMs: Type.Optional( + Type.Number({ + description: "Milliseconds to wait before backgrounding (default 10000)", + }), + ), + background: Type.Optional(Type.Boolean({ description: "Run in background immediately" })), + timeout: Type.Optional( + Type.Number({ + description: "Timeout in seconds (optional, kills process on expiry)", + }), + ), + pty: Type.Optional( + Type.Boolean({ + description: + "Run in a pseudo-terminal (PTY) when available (TTY-required CLIs, coding agents)", + }), + ), + elevated: Type.Optional( + Type.Boolean({ + description: "Run on the host with elevated permissions (if allowed)", + }), + ), + host: Type.Optional( + Type.String({ + description: "Exec host/target (auto|sandbox|gateway|node).", + }), + ), + security: Type.Optional( + Type.String({ + description: "Exec security mode (deny|allowlist|full).", + }), + ), + ask: Type.Optional( + Type.String({ + description: "Exec ask mode (off|on-miss|always).", + }), + ), + node: Type.Optional( + Type.String({ + description: "Node id/name for host=node.", + }), + ), +}); + +export const processSchema = Type.Object({ + action: Type.String({ description: "Process action" }), + sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })), + data: Type.Optional(Type.String({ description: "Data to write for write" })), + keys: Type.Optional( + Type.Array(Type.String(), { description: "Key tokens to send for send-keys" }), + ), + hex: Type.Optional(Type.Array(Type.String(), { description: "Hex bytes to send for send-keys" })), + literal: Type.Optional(Type.String({ description: "Literal string for send-keys" })), + text: Type.Optional(Type.String({ description: "Text to paste for paste" })), + bracketed: Type.Optional(Type.Boolean({ description: "Wrap paste in bracketed mode" })), + eof: Type.Optional(Type.Boolean({ description: "Close stdin after write" })), + offset: Type.Optional(Type.Number({ description: "Log offset" })), + limit: Type.Optional(Type.Number({ description: "Log length" })), + timeout: Type.Optional( + Type.Number({ + description: "For poll: wait up to this many milliseconds before returning", + minimum: 0, + }), + ), +}); diff --git a/src/agents/command/attempt-execution.helpers.ts b/src/agents/command/attempt-execution.helpers.ts new file mode 100644 index 00000000000..f9abc3401c1 --- /dev/null +++ b/src/agents/command/attempt-execution.helpers.ts @@ -0,0 +1,169 @@ +import fs from "node:fs/promises"; +import readline from "node:readline"; +import { + isSilentReplyPrefixText, + isSilentReplyText, + SILENT_REPLY_TOKEN, + startsWithSilentToken, + stripLeadingSilentToken, +} from "../../auto-reply/tokens.js"; + +/** Maximum number of JSONL records to inspect before giving up. */ +const SESSION_FILE_MAX_RECORDS = 500; + +/** + * Check whether a session transcript file exists and contains at least one + * assistant message, indicating that the SessionManager has flushed the + * initial user+assistant exchange to disk. + */ +export async function sessionFileHasContent(sessionFile: string | undefined): Promise { + if (!sessionFile) { + return false; + } + try { + // Guard against symlink-following (CWE-400 / arbitrary-file-read vector). + const stat = await fs.lstat(sessionFile); + if (stat.isSymbolicLink()) { + return false; + } + + const fh = await fs.open(sessionFile, "r"); + try { + const rl = readline.createInterface({ input: fh.createReadStream({ encoding: "utf-8" }) }); + let recordCount = 0; + for await (const line of rl) { + if (!line.trim()) { + continue; + } + recordCount++; + if (recordCount > SESSION_FILE_MAX_RECORDS) { + break; + } + let obj: unknown; + try { + obj = JSON.parse(line); + } catch { + continue; + } + const rec = obj as Record | null; + if ( + rec?.type === "message" && + (rec.message as Record | undefined)?.role === "assistant" + ) { + return true; + } + } + return false; + } finally { + await fh.close(); + } + } catch { + return false; + } +} + +export function resolveFallbackRetryPrompt(params: { + body: string; + isFallbackRetry: boolean; + sessionHasHistory?: boolean; +}): string { + if (!params.isFallbackRetry) { + return params.body; + } + if (!params.sessionHasHistory) { + return params.body; + } + return "Continue where you left off. The previous model attempt failed or timed out."; +} + +export function createAcpVisibleTextAccumulator() { + let pendingSilentPrefix = ""; + let visibleText = ""; + let rawVisibleText = ""; + const startsWithWordChar = (chunk: string): boolean => /^[\p{L}\p{N}]/u.test(chunk); + + const resolveNextCandidate = (base: string, chunk: string): string => { + if (!base) { + return chunk; + } + if ( + isSilentReplyText(base, SILENT_REPLY_TOKEN) && + !chunk.startsWith(base) && + startsWithWordChar(chunk) + ) { + return chunk; + } + if (chunk.startsWith(base) && chunk.length > base.length) { + return chunk; + } + return `${base}${chunk}`; + }; + + const mergeVisibleChunk = (base: string, chunk: string): { rawText: string; delta: string } => { + if (!base) { + return { rawText: chunk, delta: chunk }; + } + if (chunk.startsWith(base) && chunk.length > base.length) { + const delta = chunk.slice(base.length); + return { rawText: chunk, delta }; + } + return { + rawText: `${base}${chunk}`, + delta: chunk, + }; + }; + + return { + consume(chunk: string): { text: string; delta: string } | null { + if (!chunk) { + return null; + } + + if (!visibleText) { + const leadCandidate = resolveNextCandidate(pendingSilentPrefix, chunk); + const trimmedLeadCandidate = leadCandidate.trim(); + if ( + isSilentReplyText(trimmedLeadCandidate, SILENT_REPLY_TOKEN) || + isSilentReplyPrefixText(trimmedLeadCandidate, SILENT_REPLY_TOKEN) + ) { + pendingSilentPrefix = leadCandidate; + return null; + } + if (startsWithSilentToken(trimmedLeadCandidate, SILENT_REPLY_TOKEN)) { + const stripped = stripLeadingSilentToken(leadCandidate, SILENT_REPLY_TOKEN); + if (stripped) { + pendingSilentPrefix = ""; + rawVisibleText = leadCandidate; + visibleText = stripped; + return { text: stripped, delta: stripped }; + } + pendingSilentPrefix = leadCandidate; + return null; + } + if (pendingSilentPrefix) { + pendingSilentPrefix = ""; + rawVisibleText = leadCandidate; + visibleText = leadCandidate; + return { + text: visibleText, + delta: leadCandidate, + }; + } + } + + const nextVisible = mergeVisibleChunk(rawVisibleText, chunk); + rawVisibleText = nextVisible.rawText; + if (!nextVisible.delta) { + return null; + } + visibleText = `${visibleText}${nextVisible.delta}`; + return { text: visibleText, delta: nextVisible.delta }; + }, + finalize(): string { + return visibleText.trim(); + }, + finalizeRaw(): string { + return visibleText; + }, + }; +} diff --git a/src/agents/command/attempt-execution.test.ts b/src/agents/command/attempt-execution.test.ts index 7ed18dbe23f..b1b5ab72700 100644 --- a/src/agents/command/attempt-execution.test.ts +++ b/src/agents/command/attempt-execution.test.ts @@ -6,7 +6,7 @@ import { createAcpVisibleTextAccumulator, resolveFallbackRetryPrompt, sessionFileHasContent, -} from "./attempt-execution.js"; +} from "./attempt-execution.helpers.js"; describe("resolveFallbackRetryPrompt", () => { const originalBody = "Summarize the quarterly earnings report and highlight key trends."; diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 69e2c50fbde..1549dd4568c 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -1,15 +1,7 @@ import fs from "node:fs/promises"; -import readline from "node:readline"; import { SessionManager } from "@mariozechner/pi-coding-agent"; import { normalizeReplyPayload } from "../../auto-reply/reply/normalize-reply.js"; import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js"; -import { - isSilentReplyPrefixText, - isSilentReplyText, - SILENT_REPLY_TOKEN, - startsWithSilentToken, - stripLeadingSilentToken, -} from "../../auto-reply/tokens.js"; import { mergeSessionEntry, type SessionEntry, updateSessionStore } from "../../config/sessions.js"; import { resolveSessionTranscriptFile } from "../../config/sessions/transcript.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -28,72 +20,18 @@ import { isCliProvider } from "../model-selection.js"; import { prepareSessionManagerForRun } from "../pi-embedded-runner/session-manager-init.js"; import { runEmbeddedPiAgent } from "../pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../skills.js"; +import { resolveFallbackRetryPrompt } from "./attempt-execution.helpers.js"; import { resolveAgentRunContext } from "./run-context.js"; import type { AgentCommandOpts } from "./types.js"; +export { + createAcpVisibleTextAccumulator, + resolveFallbackRetryPrompt, + sessionFileHasContent, +} from "./attempt-execution.helpers.js"; + const log = createSubsystemLogger("agents/agent-command"); -/** Maximum number of JSONL records to inspect before giving up. */ -const SESSION_FILE_MAX_RECORDS = 500; - -/** - * Check whether a session transcript file exists and contains at least one - * assistant message, indicating that the SessionManager has flushed the - * initial user+assistant exchange to disk. This is used to decide whether - * a fallback retry can rely on the on-disk history or must re-send the - * original prompt. - * - * The check parses JSONL records line-by-line (CWE-703) instead of relying - * on a raw substring match against a bounded byte prefix, which could - * produce false negatives when the pre-assistant content exceeds the byte - * limit. - */ -export async function sessionFileHasContent(sessionFile: string | undefined): Promise { - if (!sessionFile) { - return false; - } - try { - // Guard against symlink-following (CWE-400 / arbitrary-file-read vector). - const stat = await fs.lstat(sessionFile); - if (stat.isSymbolicLink()) { - return false; - } - - const fh = await fs.open(sessionFile, "r"); - try { - const rl = readline.createInterface({ input: fh.createReadStream({ encoding: "utf-8" }) }); - let recordCount = 0; - for await (const line of rl) { - if (!line.trim()) { - continue; - } - recordCount++; - if (recordCount > SESSION_FILE_MAX_RECORDS) { - break; - } - let obj: unknown; - try { - obj = JSON.parse(line); - } catch { - continue; - } - const rec = obj as Record | null; - if ( - rec?.type === "message" && - (rec.message as Record | undefined)?.role === "assistant" - ) { - return true; - } - } - return false; - } finally { - await fh.close(); - } - } catch { - return false; - } -} - export type PersistSessionEntryParams = { sessionStore: Record; sessionKey: string; @@ -116,25 +54,6 @@ export async function persistSessionEntry(params: PersistSessionEntryParams): Pr params.sessionStore[params.sessionKey] = persisted; } -export function resolveFallbackRetryPrompt(params: { - body: string; - isFallbackRetry: boolean; - sessionHasHistory?: boolean; -}): string { - if (!params.isFallbackRetry) { - return params.body; - } - // When the session has no persisted history (e.g. a freshly-spawned subagent - // whose first attempt failed before the SessionManager flushed the user - // message to disk), the fallback model would receive only the generic - // recovery prompt and lose the original task entirely. Preserve the - // original body in that case so the fallback model can execute the task. - if (!params.sessionHasHistory) { - return params.body; - } - return "Continue where you left off. The previous model attempt failed or timed out."; -} - export function prependInternalEventContext( body: string, events: AgentCommandOpts["internalEvents"], @@ -149,100 +68,6 @@ export function prependInternalEventContext( return [renderedEvents, body].filter(Boolean).join("\n\n"); } -export function createAcpVisibleTextAccumulator() { - let pendingSilentPrefix = ""; - let visibleText = ""; - let rawVisibleText = ""; - const startsWithWordChar = (chunk: string): boolean => /^[\p{L}\p{N}]/u.test(chunk); - - const resolveNextCandidate = (base: string, chunk: string): string => { - if (!base) { - return chunk; - } - if ( - isSilentReplyText(base, SILENT_REPLY_TOKEN) && - !chunk.startsWith(base) && - startsWithWordChar(chunk) - ) { - return chunk; - } - if (chunk.startsWith(base) && chunk.length > base.length) { - return chunk; - } - return `${base}${chunk}`; - }; - - const mergeVisibleChunk = (base: string, chunk: string): { rawText: string; delta: string } => { - if (!base) { - return { rawText: chunk, delta: chunk }; - } - if (chunk.startsWith(base) && chunk.length > base.length) { - const delta = chunk.slice(base.length); - return { rawText: chunk, delta }; - } - return { - rawText: `${base}${chunk}`, - delta: chunk, - }; - }; - - return { - consume(chunk: string): { text: string; delta: string } | null { - if (!chunk) { - return null; - } - - if (!visibleText) { - const leadCandidate = resolveNextCandidate(pendingSilentPrefix, chunk); - const trimmedLeadCandidate = leadCandidate.trim(); - if ( - isSilentReplyText(trimmedLeadCandidate, SILENT_REPLY_TOKEN) || - isSilentReplyPrefixText(trimmedLeadCandidate, SILENT_REPLY_TOKEN) - ) { - pendingSilentPrefix = leadCandidate; - return null; - } - // Strip leading NO_REPLY token when it is glued to visible text - // (e.g. "NO_REPLYThe user is saying...") so the token never leaks. - if (startsWithSilentToken(trimmedLeadCandidate, SILENT_REPLY_TOKEN)) { - const stripped = stripLeadingSilentToken(leadCandidate, SILENT_REPLY_TOKEN); - if (stripped) { - pendingSilentPrefix = ""; - rawVisibleText = leadCandidate; - visibleText = stripped; - return { text: stripped, delta: stripped }; - } - pendingSilentPrefix = leadCandidate; - return null; - } - if (pendingSilentPrefix) { - pendingSilentPrefix = ""; - rawVisibleText = leadCandidate; - visibleText = leadCandidate; - return { - text: visibleText, - delta: leadCandidate, - }; - } - } - - const nextVisible = mergeVisibleChunk(rawVisibleText, chunk); - rawVisibleText = nextVisible.rawText; - if (!nextVisible.delta) { - return null; - } - visibleText = `${visibleText}${nextVisible.delta}`; - return { text: visibleText, delta: nextVisible.delta }; - }, - finalize(): string { - return visibleText.trim(); - }, - finalizeRaw(): string { - return visibleText; - }, - }; -} - const ACP_TRANSCRIPT_USAGE = { input: 0, output: 0, 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 ebf255e0e56..4693ea0048f 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,44 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.unmock("../plugins/manifest-registry.js"); -vi.unmock("../plugins/provider-runtime.js"); -vi.unmock("../plugins/provider-runtime.runtime.js"); -vi.unmock("../secrets/provider-env-vars.js"); - -async function loadSecretsModule() { - vi.doUnmock("../plugins/manifest-registry.js"); - vi.doUnmock("../plugins/provider-runtime.js"); - vi.doUnmock("../plugins/provider-runtime.runtime.js"); - vi.doUnmock("../secrets/provider-env-vars.js"); - vi.resetModules(); - const [{ resetProviderRuntimeHookCacheForTest }, { resetPluginLoaderTestStateForTest }] = - await Promise.all([ - import("../plugins/provider-runtime.js"), - import("../plugins/loader.test-fixtures.js"), - ]); - resetPluginLoaderTestStateForTest(); - resetProviderRuntimeHookCacheForTest(); - return import("./models-config.providers.secrets.js"); -} - -beforeEach(async () => { - vi.doUnmock("../plugins/manifest-registry.js"); - vi.doUnmock("../plugins/provider-runtime.js"); - vi.doUnmock("../plugins/provider-runtime.runtime.js"); - vi.doUnmock("../secrets/provider-env-vars.js"); - vi.resetModules(); - const [{ resetProviderRuntimeHookCacheForTest }, { resetPluginLoaderTestStateForTest }] = - await Promise.all([ - import("../plugins/provider-runtime.js"), - import("../plugins/loader.test-fixtures.js"), - ]); - resetPluginLoaderTestStateForTest(); - resetProviderRuntimeHookCacheForTest(); -}); +import { describe, expect, it } from "vitest"; +import { resolveMissingProviderApiKey } from "./models-config.providers.secret-helpers.js"; describe("models-config", () => { - it("fills missing provider.apiKey from env var name when models exist", async () => { - const { resolveMissingProviderApiKey } = await loadSecretsModule(); + it("fills missing provider.apiKey from env var name when models exist", () => { const provider = resolveMissingProviderApiKey({ providerKey: "minimax", provider: { diff --git a/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts index 4a43c17577e..ad94327692f 100644 --- a/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts +++ b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; import type { ApiKeyCredential } from "./auth-profiles/types.js"; import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; -import { resolveApiKeyFromCredential } from "./models-config.providers.secrets.js"; +import { resolveApiKeyFromCredential } from "./models-config.providers.secret-helpers.js"; function expectedCloudflareGatewayBaseUrl(accountId: string, gatewayId: string): string { return `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/anthropic`; diff --git a/src/agents/models-config.providers.discovery-auth.test.ts b/src/agents/models-config.providers.discovery-auth.test.ts index 835c1ce38f4..ce816cee656 100644 --- a/src/agents/models-config.providers.discovery-auth.test.ts +++ b/src/agents/models-config.providers.discovery-auth.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; -import { resolveApiKeyFromCredential } from "./models-config.providers.secrets.js"; +import { resolveApiKeyFromCredential } from "./models-config.providers.secret-helpers.js"; describe("provider discovery auth marker guardrails", () => { it("suppresses discovery secrets for marker-backed vLLM credentials", () => { diff --git a/src/agents/models-config.providers.moonshot.test.ts b/src/agents/models-config.providers.moonshot.test.ts index 8640e3e8695..8d9d1f68938 100644 --- a/src/agents/models-config.providers.moonshot.test.ts +++ b/src/agents/models-config.providers.moonshot.test.ts @@ -1,20 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { ModelProviderConfig } from "../config/types.models.js"; import { applyProviderNativeStreamingUsageCompat } from "../plugin-sdk/provider-catalog-shared.js"; -import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js"; - -async function loadSecretsModule() { - vi.doUnmock("../plugins/manifest-registry.js"); - vi.doUnmock("../secrets/provider-env-vars.js"); - vi.resetModules(); - return import("./models-config.providers.secrets.js"); -} - -beforeEach(() => { - resetProviderRuntimeHookCacheForTest(); - vi.doUnmock("../plugins/manifest-registry.js"); - vi.doUnmock("../secrets/provider-env-vars.js"); -}); +import { resolveMissingProviderApiKey } from "./models-config.providers.secret-helpers.js"; const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; const MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1"; @@ -70,8 +57,7 @@ describe("moonshot implicit provider (#33637)", () => { ).toBeUndefined(); }); - it("includes moonshot when MOONSHOT_API_KEY is configured", async () => { - const { resolveMissingProviderApiKey } = await loadSecretsModule(); + it("includes moonshot when MOONSHOT_API_KEY is configured", () => { const provider = resolveMissingProviderApiKey({ providerKey: "moonshot", provider: buildMoonshotProvider(), diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts index d78a17a635d..a70875f7f63 100644 --- a/src/agents/models-config.providers.normalize-keys.test.ts +++ b/src/agents/models-config.providers.normalize-keys.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { normalizeProviders } from "./models-config.providers.normalize.js"; -import { resolveApiKeyFromProfiles } from "./models-config.providers.secrets.js"; +import { resolveApiKeyFromProfiles } from "./models-config.providers.secret-helpers.js"; import { enforceSourceManagedProviderSecrets } from "./models-config.providers.source-managed.js"; vi.mock("./models-config.providers.policy.runtime.js", () => ({ diff --git a/src/agents/models-config.providers.normalize.ts b/src/agents/models-config.providers.normalize.ts index 48478286bb9..9648f0406d1 100644 --- a/src/agents/models-config.providers.normalize.ts +++ b/src/agents/models-config.providers.normalize.ts @@ -4,14 +4,14 @@ import { normalizeProviderSpecificConfig, resolveProviderConfigApiKeyResolver, } from "./models-config.providers.policy.js"; -import type { ProviderConfig, SecretDefaults } from "./models-config.providers.secrets.js"; +import type { ProviderConfig, SecretDefaults } from "./models-config.providers.secret-helpers.js"; import { normalizeConfiguredProviderApiKey, normalizeHeaderValues, normalizeResolvedEnvApiKey, resolveApiKeyFromProfiles, resolveMissingProviderApiKey, -} from "./models-config.providers.secrets.js"; +} from "./models-config.providers.secret-helpers.js"; import { enforceSourceManagedProviderSecrets } from "./models-config.providers.source-managed.js"; type ModelsConfig = NonNullable; diff --git a/src/agents/models-config.providers.nvidia.test.ts b/src/agents/models-config.providers.nvidia.test.ts index 46b0eb8b741..d76b83aaf4d 100644 --- a/src/agents/models-config.providers.nvidia.test.ts +++ b/src/agents/models-config.providers.nvidia.test.ts @@ -4,7 +4,7 @@ import { resolveEnvApiKey } from "./model-auth-env.js"; import { resolveEnvApiKeyVarName, resolveMissingProviderApiKey, -} from "./models-config.providers.secrets.js"; +} from "./models-config.providers.secret-helpers.js"; const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; const MINIMAX_BASE_URL = "https://api.minimax.io/anthropic"; diff --git a/src/agents/models-config.providers.secret-helpers.ts b/src/agents/models-config.providers.secret-helpers.ts new file mode 100644 index 00000000000..9735df227e4 --- /dev/null +++ b/src/agents/models-config.providers.secret-helpers.ts @@ -0,0 +1,320 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; +import { resolveEnvApiKey } from "./model-auth-env.js"; +import { + isNonSecretApiKeyMarker, + resolveEnvSecretRefHeaderValueMarker, + resolveNonEnvSecretRefApiKeyMarker, + resolveNonEnvSecretRefHeaderValueMarker, +} from "./model-auth-markers.js"; +import { resolveAwsSdkEnvVarName } from "./model-auth-runtime-shared.js"; +import { resolveProviderIdForAuth } from "./provider-auth-aliases.js"; + +type ModelsConfig = NonNullable; +export type ProviderConfig = NonNullable[string]; + +export type SecretDefaults = { + env?: string; + file?: string; + exec?: string; +}; + +export type ProfileApiKeyResolution = { + apiKey: string; + source: "plaintext" | "env-ref" | "non-env-ref"; + discoveryApiKey?: string; +}; + +export type ProviderApiKeyResolver = (provider: string) => { + apiKey: string | undefined; + discoveryApiKey?: string; +}; + +export type ProviderAuthResolver = ( + provider: string, + options?: { oauthMarker?: string }, +) => { + apiKey: string | undefined; + discoveryApiKey?: string; + mode: "api_key" | "oauth" | "token" | "none"; + source: "env" | "profile" | "none"; + profileId?: string; +}; + +const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; + +export function normalizeApiKeyConfig(value: string): string { + const trimmed = value.trim(); + const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed); + return match?.[1] ?? trimmed; +} + +export function toDiscoveryApiKey(value: string | undefined): string | undefined { + const trimmed = normalizeOptionalString(value); + if (!trimmed || isNonSecretApiKeyMarker(trimmed)) { + return undefined; + } + return trimmed; +} + +export function resolveEnvApiKeyVarName( + provider: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const resolved = resolveEnvApiKey(provider, env); + if (!resolved) { + return undefined; + } + const match = /^(?:env: |shell env: )([A-Z0-9_]+)$/.exec(resolved.source); + return match ? match[1] : undefined; +} + +export function resolveAwsSdkApiKeyVarName( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + return resolveAwsSdkEnvVarName(env); +} + +export function normalizeHeaderValues(params: { + headers: ProviderConfig["headers"] | undefined; + secretDefaults: SecretDefaults | undefined; +}): { headers: ProviderConfig["headers"] | undefined; mutated: boolean } { + const { headers } = params; + if (!headers) { + return { headers, mutated: false }; + } + let mutated = false; + const nextHeaders: Record[string]> = {}; + for (const [headerName, headerValue] of Object.entries(headers)) { + const resolvedRef = resolveSecretInputRef({ + value: headerValue, + defaults: params.secretDefaults, + }).ref; + if (!resolvedRef || !resolvedRef.id.trim()) { + nextHeaders[headerName] = headerValue; + continue; + } + mutated = true; + nextHeaders[headerName] = + resolvedRef.source === "env" + ? resolveEnvSecretRefHeaderValueMarker(resolvedRef.id) + : resolveNonEnvSecretRefHeaderValueMarker(resolvedRef.source); + } + if (!mutated) { + return { headers, mutated: false }; + } + return { headers: nextHeaders, mutated: true }; +} + +export function resolveApiKeyFromCredential( + cred: AuthProfileStore["profiles"][string] | undefined, + env: NodeJS.ProcessEnv = process.env, +): ProfileApiKeyResolution | undefined { + if (!cred) { + return undefined; + } + if (cred.type === "api_key") { + const keyRef = coerceSecretRef(cred.keyRef); + if (keyRef && keyRef.id.trim()) { + if (keyRef.source === "env") { + const envVar = keyRef.id.trim(); + return { + apiKey: envVar, + source: "env-ref", + discoveryApiKey: toDiscoveryApiKey(env[envVar]), + }; + } + return { + apiKey: resolveNonEnvSecretRefApiKeyMarker(keyRef.source), + source: "non-env-ref", + }; + } + if (cred.key?.trim()) { + return { + apiKey: cred.key, + source: "plaintext", + discoveryApiKey: toDiscoveryApiKey(cred.key), + }; + } + return undefined; + } + if (cred.type === "token") { + const tokenRef = coerceSecretRef(cred.tokenRef); + if (tokenRef && tokenRef.id.trim()) { + if (tokenRef.source === "env") { + const envVar = tokenRef.id.trim(); + return { + apiKey: envVar, + source: "env-ref", + discoveryApiKey: toDiscoveryApiKey(env[envVar]), + }; + } + return { + apiKey: resolveNonEnvSecretRefApiKeyMarker(tokenRef.source), + source: "non-env-ref", + }; + } + if (cred.token?.trim()) { + return { + apiKey: cred.token, + source: "plaintext", + discoveryApiKey: toDiscoveryApiKey(cred.token), + }; + } + } + return undefined; +} + +export function listAuthProfilesForProvider(store: AuthProfileStore, provider: string): string[] { + const providerKey = resolveProviderIdForAuth(provider); + return Object.entries(store.profiles) + .filter(([, cred]) => resolveProviderIdForAuth(cred.provider) === providerKey) + .map(([id]) => id); +} + +export function resolveApiKeyFromProfiles(params: { + provider: string; + store: AuthProfileStore; + env?: NodeJS.ProcessEnv; +}): ProfileApiKeyResolution | undefined { + const ids = listAuthProfilesForProvider(params.store, params.provider); + for (const id of ids) { + const resolved = resolveApiKeyFromCredential(params.store.profiles[id], params.env); + if (resolved) { + return resolved; + } + } + return undefined; +} + +export function normalizeConfiguredProviderApiKey(params: { + providerKey: string; + provider: ProviderConfig; + secretDefaults: SecretDefaults | undefined; + profileApiKey: ProfileApiKeyResolution | undefined; + secretRefManagedProviders?: Set; +}): ProviderConfig { + const configuredApiKey = params.provider.apiKey; + const configuredApiKeyRef = resolveSecretInputRef({ + value: configuredApiKey, + defaults: params.secretDefaults, + }).ref; + + if (configuredApiKeyRef && configuredApiKeyRef.id.trim()) { + const marker = + configuredApiKeyRef.source === "env" + ? configuredApiKeyRef.id.trim() + : resolveNonEnvSecretRefApiKeyMarker(configuredApiKeyRef.source); + params.secretRefManagedProviders?.add(params.providerKey); + if (params.provider.apiKey === marker) { + return params.provider; + } + return { + ...params.provider, + apiKey: marker, + }; + } + + if (typeof configuredApiKey !== "string") { + return params.provider; + } + + const normalizedConfiguredApiKey = normalizeApiKeyConfig(configuredApiKey); + if (isNonSecretApiKeyMarker(normalizedConfiguredApiKey)) { + params.secretRefManagedProviders?.add(params.providerKey); + } + if ( + params.profileApiKey && + params.profileApiKey.source !== "plaintext" && + normalizedConfiguredApiKey === params.profileApiKey.apiKey + ) { + params.secretRefManagedProviders?.add(params.providerKey); + } + if (normalizedConfiguredApiKey === configuredApiKey) { + return params.provider; + } + return { + ...params.provider, + apiKey: normalizedConfiguredApiKey, + }; +} + +export function normalizeResolvedEnvApiKey(params: { + providerKey: string; + provider: ProviderConfig; + env: NodeJS.ProcessEnv; + secretRefManagedProviders?: Set; +}): ProviderConfig { + const currentApiKey = params.provider.apiKey; + if ( + typeof currentApiKey !== "string" || + !currentApiKey.trim() || + ENV_VAR_NAME_RE.test(currentApiKey.trim()) + ) { + return params.provider; + } + + const envVarName = resolveEnvApiKeyVarName(params.providerKey, params.env); + if (!envVarName || params.env[envVarName] !== currentApiKey) { + return params.provider; + } + params.secretRefManagedProviders?.add(params.providerKey); + return { + ...params.provider, + apiKey: envVarName, + }; +} + +export function resolveMissingProviderApiKey(params: { + providerKey: string; + provider: ProviderConfig; + env: NodeJS.ProcessEnv; + profileApiKey: ProfileApiKeyResolution | undefined; + secretRefManagedProviders?: Set; + providerApiKeyResolver?: (env: NodeJS.ProcessEnv) => string | undefined; +}): ProviderConfig { + const hasModels = Array.isArray(params.provider.models) && params.provider.models.length > 0; + const normalizedApiKey = normalizeOptionalSecretInput(params.provider.apiKey); + const hasConfiguredApiKey = Boolean(normalizedApiKey || params.provider.apiKey); + if (!hasModels || hasConfiguredApiKey) { + return params.provider; + } + + const authMode = params.provider.auth; + if (params.providerApiKeyResolver && (!authMode || authMode === "aws-sdk")) { + const resolvedApiKey = params.providerApiKeyResolver(params.env); + if (!resolvedApiKey) { + return params.provider; + } + return { + ...params.provider, + apiKey: resolvedApiKey, + }; + } + if (authMode === "aws-sdk") { + const awsEnvVar = resolveAwsSdkApiKeyVarName(params.env); + if (!awsEnvVar) { + return params.provider; + } + return { + ...params.provider, + apiKey: awsEnvVar, + }; + } + + const fromEnv = resolveEnvApiKeyVarName(params.providerKey, params.env); + const apiKey = fromEnv ?? params.profileApiKey?.apiKey; + if (!apiKey?.trim()) { + return params.provider; + } + if (params.profileApiKey && params.profileApiKey.source !== "plaintext") { + params.secretRefManagedProviders?.add(params.providerKey); + } + return { + ...params.provider, + apiKey, + }; +} diff --git a/src/agents/models-config.providers.secrets.bedrock-apikey.test.ts b/src/agents/models-config.providers.secrets.bedrock-apikey.test.ts index 6cab5c399e3..20907c3e8d3 100644 --- a/src/agents/models-config.providers.secrets.bedrock-apikey.test.ts +++ b/src/agents/models-config.providers.secrets.bedrock-apikey.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import type { ProviderConfig } from "./models-config.providers.secrets.js"; +import type { ProviderConfig } from "./models-config.providers.secret-helpers.js"; import { resolveAwsSdkApiKeyVarName, resolveMissingProviderApiKey, -} from "./models-config.providers.secrets.js"; +} from "./models-config.providers.secret-helpers.js"; /** * Regression tests for #49891 / #50699 / #54274: diff --git a/src/agents/models-config.providers.secrets.ts b/src/agents/models-config.providers.secrets.ts index f2efb75529e..0a3e9bf7c8f 100644 --- a/src/agents/models-config.providers.secrets.ts +++ b/src/agents/models-config.providers.secrets.ts @@ -1,332 +1,49 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; import { resolveProviderSyntheticAuthWithPlugin } from "../plugins/provider-runtime.js"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; -import { listProfilesForProvider } from "./auth-profiles/profiles.js"; -import { ensureAuthProfileStore } from "./auth-profiles/store.js"; -import { resolveEnvApiKey } from "./model-auth-env.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; import { isNonSecretApiKeyMarker, - resolveEnvSecretRefHeaderValueMarker, resolveNonEnvSecretRefApiKeyMarker, - resolveNonEnvSecretRefHeaderValueMarker, } from "./model-auth-markers.js"; -import { resolveAwsSdkEnvVarName } from "./model-auth-runtime-shared.js"; +import { + listAuthProfilesForProvider, + resolveApiKeyFromCredential, + resolveApiKeyFromProfiles, + resolveEnvApiKeyVarName, + toDiscoveryApiKey, + type ProviderApiKeyResolver, + type ProviderAuthResolver, +} from "./models-config.providers.secret-helpers.js"; import { resolveProviderIdForAuth } from "./provider-auth-aliases.js"; -type ModelsConfig = NonNullable; -export type ProviderConfig = NonNullable[string]; +export type { + ProfileApiKeyResolution, + ProviderApiKeyResolver, + ProviderAuthResolver, + ProviderConfig, + SecretDefaults, +} from "./models-config.providers.secret-helpers.js"; -export type SecretDefaults = { - env?: string; - file?: string; - exec?: string; -}; +export { + listAuthProfilesForProvider, + normalizeApiKeyConfig, + normalizeConfiguredProviderApiKey, + normalizeHeaderValues, + normalizeResolvedEnvApiKey, + resolveApiKeyFromCredential, + resolveApiKeyFromProfiles, + resolveAwsSdkApiKeyVarName, + resolveEnvApiKeyVarName, + resolveMissingProviderApiKey, + toDiscoveryApiKey, +} from "./models-config.providers.secret-helpers.js"; -export type ProfileApiKeyResolution = { - apiKey: string; - source: "plaintext" | "env-ref" | "non-env-ref"; - discoveryApiKey?: string; -}; - -export type ProviderApiKeyResolver = (provider: string) => { - apiKey: string | undefined; - discoveryApiKey?: string; -}; - -export type ProviderAuthResolver = ( - provider: string, - options?: { oauthMarker?: string }, -) => { - apiKey: string | undefined; - discoveryApiKey?: string; - mode: "api_key" | "oauth" | "token" | "none"; - source: "env" | "profile" | "none"; - profileId?: string; -}; - -type AuthProfileStoreInput = - | ReturnType - | (() => ReturnType); +type AuthProfileStoreInput = AuthProfileStore | (() => AuthProfileStore); function resolveAuthProfileStoreInput(input: AuthProfileStoreInput) { return typeof input === "function" ? input() : input; } -const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; - -export function normalizeApiKeyConfig(value: string): string { - const trimmed = value.trim(); - const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed); - return match?.[1] ?? trimmed; -} - -export function toDiscoveryApiKey(value: string | undefined): string | undefined { - const trimmed = normalizeOptionalString(value); - if (!trimmed || isNonSecretApiKeyMarker(trimmed)) { - return undefined; - } - return trimmed; -} - -export function resolveEnvApiKeyVarName( - provider: string, - env: NodeJS.ProcessEnv = process.env, -): string | undefined { - const resolved = resolveEnvApiKey(provider, env); - if (!resolved) { - return undefined; - } - const match = /^(?:env: |shell env: )([A-Z0-9_]+)$/.exec(resolved.source); - return match ? match[1] : undefined; -} - -export function resolveAwsSdkApiKeyVarName( - env: NodeJS.ProcessEnv = process.env, -): string | undefined { - return resolveAwsSdkEnvVarName(env); -} - -export function normalizeHeaderValues(params: { - headers: ProviderConfig["headers"] | undefined; - secretDefaults: SecretDefaults | undefined; -}): { headers: ProviderConfig["headers"] | undefined; mutated: boolean } { - const { headers } = params; - if (!headers) { - return { headers, mutated: false }; - } - let mutated = false; - const nextHeaders: Record[string]> = {}; - for (const [headerName, headerValue] of Object.entries(headers)) { - const resolvedRef = resolveSecretInputRef({ - value: headerValue, - defaults: params.secretDefaults, - }).ref; - if (!resolvedRef || !resolvedRef.id.trim()) { - nextHeaders[headerName] = headerValue; - continue; - } - mutated = true; - nextHeaders[headerName] = - resolvedRef.source === "env" - ? resolveEnvSecretRefHeaderValueMarker(resolvedRef.id) - : resolveNonEnvSecretRefHeaderValueMarker(resolvedRef.source); - } - if (!mutated) { - return { headers, mutated: false }; - } - return { headers: nextHeaders, mutated: true }; -} - -export function resolveApiKeyFromCredential( - cred: ReturnType["profiles"][string] | undefined, - env: NodeJS.ProcessEnv = process.env, -): ProfileApiKeyResolution | undefined { - if (!cred) { - return undefined; - } - if (cred.type === "api_key") { - const keyRef = coerceSecretRef(cred.keyRef); - if (keyRef && keyRef.id.trim()) { - if (keyRef.source === "env") { - const envVar = keyRef.id.trim(); - return { - apiKey: envVar, - source: "env-ref", - discoveryApiKey: toDiscoveryApiKey(env[envVar]), - }; - } - return { - apiKey: resolveNonEnvSecretRefApiKeyMarker(keyRef.source), - source: "non-env-ref", - }; - } - if (cred.key?.trim()) { - return { - apiKey: cred.key, - source: "plaintext", - discoveryApiKey: toDiscoveryApiKey(cred.key), - }; - } - return undefined; - } - if (cred.type === "token") { - const tokenRef = coerceSecretRef(cred.tokenRef); - if (tokenRef && tokenRef.id.trim()) { - if (tokenRef.source === "env") { - const envVar = tokenRef.id.trim(); - return { - apiKey: envVar, - source: "env-ref", - discoveryApiKey: toDiscoveryApiKey(env[envVar]), - }; - } - return { - apiKey: resolveNonEnvSecretRefApiKeyMarker(tokenRef.source), - source: "non-env-ref", - }; - } - if (cred.token?.trim()) { - return { - apiKey: cred.token, - source: "plaintext", - discoveryApiKey: toDiscoveryApiKey(cred.token), - }; - } - } - return undefined; -} - -export function resolveApiKeyFromProfiles(params: { - provider: string; - store: ReturnType; - env?: NodeJS.ProcessEnv; -}): ProfileApiKeyResolution | undefined { - const ids = listProfilesForProvider(params.store, params.provider); - for (const id of ids) { - const resolved = resolveApiKeyFromCredential(params.store.profiles[id], params.env); - if (resolved) { - return resolved; - } - } - return undefined; -} - -export function normalizeConfiguredProviderApiKey(params: { - providerKey: string; - provider: ProviderConfig; - secretDefaults: SecretDefaults | undefined; - profileApiKey: ProfileApiKeyResolution | undefined; - secretRefManagedProviders?: Set; -}): ProviderConfig { - const configuredApiKey = params.provider.apiKey; - const configuredApiKeyRef = resolveSecretInputRef({ - value: configuredApiKey, - defaults: params.secretDefaults, - }).ref; - - if (configuredApiKeyRef && configuredApiKeyRef.id.trim()) { - const marker = - configuredApiKeyRef.source === "env" - ? configuredApiKeyRef.id.trim() - : resolveNonEnvSecretRefApiKeyMarker(configuredApiKeyRef.source); - params.secretRefManagedProviders?.add(params.providerKey); - if (params.provider.apiKey === marker) { - return params.provider; - } - return { - ...params.provider, - apiKey: marker, - }; - } - - if (typeof configuredApiKey !== "string") { - return params.provider; - } - - const normalizedConfiguredApiKey = normalizeApiKeyConfig(configuredApiKey); - if (isNonSecretApiKeyMarker(normalizedConfiguredApiKey)) { - params.secretRefManagedProviders?.add(params.providerKey); - } - if ( - params.profileApiKey && - params.profileApiKey.source !== "plaintext" && - normalizedConfiguredApiKey === params.profileApiKey.apiKey - ) { - params.secretRefManagedProviders?.add(params.providerKey); - } - if (normalizedConfiguredApiKey === configuredApiKey) { - return params.provider; - } - return { - ...params.provider, - apiKey: normalizedConfiguredApiKey, - }; -} - -export function normalizeResolvedEnvApiKey(params: { - providerKey: string; - provider: ProviderConfig; - env: NodeJS.ProcessEnv; - secretRefManagedProviders?: Set; -}): ProviderConfig { - const currentApiKey = params.provider.apiKey; - if ( - typeof currentApiKey !== "string" || - !currentApiKey.trim() || - ENV_VAR_NAME_RE.test(currentApiKey.trim()) - ) { - return params.provider; - } - - const envVarName = resolveEnvApiKeyVarName(params.providerKey, params.env); - if (!envVarName || params.env[envVarName] !== currentApiKey) { - return params.provider; - } - params.secretRefManagedProviders?.add(params.providerKey); - return { - ...params.provider, - apiKey: envVarName, - }; -} - -export function resolveMissingProviderApiKey(params: { - providerKey: string; - provider: ProviderConfig; - env: NodeJS.ProcessEnv; - profileApiKey: ProfileApiKeyResolution | undefined; - secretRefManagedProviders?: Set; - providerApiKeyResolver?: (env: NodeJS.ProcessEnv) => string | undefined; -}): ProviderConfig { - const hasModels = Array.isArray(params.provider.models) && params.provider.models.length > 0; - const normalizedApiKey = normalizeOptionalSecretInput(params.provider.apiKey); - const hasConfiguredApiKey = Boolean(normalizedApiKey || params.provider.apiKey); - if (!hasModels || hasConfiguredApiKey) { - return params.provider; - } - - const authMode = params.provider.auth; - if (params.providerApiKeyResolver && (!authMode || authMode === "aws-sdk")) { - const resolvedApiKey = params.providerApiKeyResolver(params.env); - if (!resolvedApiKey) { - // Resolver returned nothing (e.g. no AWS env vars on an instance-role setup). - // Don't inject an undefined/empty apiKey — let the sdk credential chain handle it. - return params.provider; - } - return { - ...params.provider, - apiKey: resolvedApiKey, - }; - } - if (authMode === "aws-sdk") { - const awsEnvVar = resolveAwsSdkApiKeyVarName(params.env); - if (!awsEnvVar) { - // No AWS env vars found — don't inject a fake apiKey marker. - // The aws-sdk credential chain (instance roles, ECS task roles, etc.) - // will resolve credentials at request time without needing an apiKey field. - return params.provider; - } - return { - ...params.provider, - apiKey: awsEnvVar, - }; - } - - const fromEnv = resolveEnvApiKeyVarName(params.providerKey, params.env); - const apiKey = fromEnv ?? params.profileApiKey?.apiKey; - if (!apiKey?.trim()) { - return params.provider; - } - if (params.profileApiKey && params.profileApiKey.source !== "plaintext") { - params.secretRefManagedProviders?.add(params.providerKey); - } - return { - ...params.provider, - apiKey, - }; -} - export function createProviderApiKeyResolver( env: NodeJS.ProcessEnv, authStoreInput: AuthProfileStoreInput, @@ -373,7 +90,7 @@ export function createProviderAuthResolver( return (provider: string, options?: { oauthMarker?: string }) => { const authProvider = resolveProviderIdForAuth(provider, { config, env }); const authStore = resolveAuthProfileStoreInput(authStoreInput); - const ids = listProfilesForProvider(authStore, authProvider); + const ids = listAuthProfilesForProvider(authStore, authProvider); let oauthCandidate: | { @@ -454,9 +171,6 @@ function resolveConfigBackedProviderAuth(params: { provider: string; config?: Op source: "config"; } | undefined { - // Providers own any provider-specific fallback auth logic via - // resolveSyntheticAuth(...). Discovery/bootstrap callers may consume - // non-secret markers from source config, but must never persist plaintext. const authProvider = resolveProviderIdForAuth(params.provider, { config: params.config }); const synthetic = resolveProviderSyntheticAuthWithPlugin({ provider: authProvider, diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 598727d55ad..db600eb0b77 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -12,13 +12,7 @@ import { emitAgentItemEvent, emitAgentPatchSummaryEvent, } from "../infra/agent-events.js"; -import { - buildExecApprovalPendingReplyPayload, - buildExecApprovalUnavailableReplyPayload, -} from "../infra/exec-approval-reply.js"; import type { ExecApprovalDecision } from "../infra/exec-approvals.js"; -import { splitMediaFromOutput } from "../media/parse.js"; -import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { PluginHookAfterToolCallEvent } from "../plugins/types.js"; import { normalizeOptionalLowercaseString, readStringValue } from "../shared/string-coerce.js"; import type { ApplyPatchSummary } from "./apply-patch.js"; @@ -43,10 +37,39 @@ import { sanitizeToolResult, } from "./pi-embedded-subscribe.tools.js"; import { inferToolMetaFromArgs } from "./pi-embedded-utils.js"; -import { consumeAdjustedParamsForToolCall } from "./pi-tools.before-tool-call.js"; import { buildToolMutationState, isSameToolMutationAction } from "./tool-mutation.js"; import { normalizeToolName } from "./tool-policy.js"; +type ExecApprovalReplyModule = typeof import("../infra/exec-approval-reply.js"); +type HookRunnerGlobalModule = typeof import("../plugins/hook-runner-global.js"); +type MediaParseModule = typeof import("../media/parse.js"); +type BeforeToolCallModule = typeof import("./pi-tools.before-tool-call.js"); + +let execApprovalReplyModulePromise: Promise | undefined; +let hookRunnerGlobalModulePromise: Promise | undefined; +let mediaParseModulePromise: Promise | undefined; +let beforeToolCallModulePromise: Promise | undefined; + +function loadExecApprovalReply(): Promise { + execApprovalReplyModulePromise ??= import("../infra/exec-approval-reply.js"); + return execApprovalReplyModulePromise; +} + +function loadHookRunnerGlobal(): Promise { + hookRunnerGlobalModulePromise ??= import("../plugins/hook-runner-global.js"); + return hookRunnerGlobalModulePromise; +} + +function loadMediaParse(): Promise { + mediaParseModulePromise ??= import("../media/parse.js"); + return mediaParseModulePromise; +} + +function loadBeforeToolCall(): Promise { + beforeToolCallModulePromise ??= import("./pi-tools.before-tool-call.js"); + return beforeToolCallModulePromise; +} + type ToolStartRecord = { startTime: number; args: unknown; @@ -285,11 +308,12 @@ function queuePendingToolMedia( } } -function collectEmittedToolOutputMediaUrls( +async function collectEmittedToolOutputMediaUrls( toolName: string, outputText: string, result: unknown, -): string[] { +): Promise { + const { splitMediaFromOutput } = await loadMediaParse(); const mediaUrls = splitMediaFromOutput(outputText).mediaUrls ?? []; if (mediaUrls.length === 0) { return []; @@ -432,6 +456,7 @@ async function emitToolResultOutput(params: { } ctx.state.deterministicApprovalPromptPending = true; try { + const { buildExecApprovalPendingReplyPayload } = await loadExecApprovalReply(); await ctx.params.onToolResult( buildExecApprovalPendingReplyPayload({ approvalId: approvalPending.approvalId, @@ -461,6 +486,7 @@ async function emitToolResultOutput(params: { } ctx.state.deterministicApprovalPromptPending = true; try { + const { buildExecApprovalUnavailableReplyPayload } = await loadExecApprovalReply(); await ctx.params.onToolResult?.( buildExecApprovalUnavailableReplyPayload({ reason: approvalUnavailable.reason, @@ -485,14 +511,14 @@ async function emitToolResultOutput(params: { ctx.shouldEmitToolOutput() || shouldEmitCompactToolOutput({ toolName, result, outputText }); if (shouldEmitOutput) { if (outputText) { + ctx.emitToolOutput(toolName, meta, outputText, result); if (ctx.params.toolResultFormat === "plain") { - emittedToolOutputMediaUrls = collectEmittedToolOutputMediaUrls( + emittedToolOutputMediaUrls = await collectEmittedToolOutputMediaUrls( toolName, outputText, result, ); } - ctx.emitToolOutput(toolName, meta, outputText, result); } if (!hasStructuredMedia) { return; @@ -827,11 +853,6 @@ export async function handleToolExecutionEnd( startData?.args && typeof startData.args === "object" ? (startData.args as Record) : {}; - const adjustedArgs = consumeAdjustedParamsForToolCall(toolCallId, runId); - const afterToolCallArgs = - adjustedArgs && typeof adjustedArgs === "object" - ? (adjustedArgs as Record) - : startArgs; const isMessagingSend = pendingMediaUrls.length > 0 || (isMessagingTool(toolName) && isMessagingToolSendAction(toolName, startArgs)); @@ -1081,8 +1102,14 @@ export async function handleToolExecutionEnd( await emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult }); // Run after_tool_call plugin hook (fire-and-forget) - const hookRunnerAfter = ctx.hookRunner ?? getGlobalHookRunner(); + const hookRunnerAfter = ctx.hookRunner ?? (await loadHookRunnerGlobal()).getGlobalHookRunner(); if (hookRunnerAfter?.hasHooks("after_tool_call")) { + const { consumeAdjustedParamsForToolCall } = await loadBeforeToolCall(); + const adjustedArgs = consumeAdjustedParamsForToolCall(toolCallId, runId); + const afterToolCallArgs = + adjustedArgs && typeof adjustedArgs === "object" + ? (adjustedArgs as Record) + : startArgs; const durationMs = startData?.startTime != null ? Date.now() - startData.startTime : undefined; const hookEvent: PluginHookAfterToolCallEvent = { toolName, diff --git a/src/agents/pi-tools-agent-config.exec.test.ts b/src/agents/pi-tools-agent-config.exec.test.ts new file mode 100644 index 00000000000..a47ddba1665 --- /dev/null +++ b/src/agents/pi-tools-agent-config.exec.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import "./test-helpers/fast-coding-tools.js"; +import "./test-helpers/fast-openclaw-tools.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js"; +import { createOpenClawCodingTools } from "./pi-tools.js"; + +function createExecHostDefaultsConfig( + agents: Array<{ id: string; execHost?: "auto" | "gateway" | "sandbox" }>, +): OpenClawConfig { + return { + tools: { + exec: { + host: "auto", + security: "full", + ask: "off", + }, + }, + agents: { + list: agents.map((agent) => ({ + id: agent.id, + ...(agent.execHost + ? { + tools: { + exec: { + host: agent.execHost, + }, + }, + } + : {}), + })), + }, + }; +} + +describe("Agent-specific exec tool defaults", () => { + beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); + }); + + it("should run exec synchronously when process is denied", async () => { + const cfg: OpenClawConfig = { + tools: { + deny: ["process"], + exec: { + host: "gateway", + security: "full", + ask: "off", + }, + }, + }; + + const tools = createOpenClawCodingTools({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir: "/tmp/test-main", + agentDir: "/tmp/agent-main", + }); + const execTool = tools.find((tool) => tool.name === "exec"); + expect(execTool).toBeDefined(); + + const result = await execTool?.execute("call1", { + command: "echo done", + yieldMs: 10, + }); + + const resultDetails = result?.details as { status?: string } | undefined; + expect(resultDetails?.status).toBe("completed"); + }); + + it("routes implicit auto exec to gateway without a sandbox runtime", async () => { + const tools = createOpenClawCodingTools({ + config: { + tools: { + exec: { + security: "full", + ask: "off", + }, + }, + }, + sessionKey: "agent:main:main", + workspaceDir: "/tmp/test-main-implicit-gateway", + agentDir: "/tmp/agent-main-implicit-gateway", + }); + const execTool = tools.find((tool) => tool.name === "exec"); + expect(execTool).toBeDefined(); + + const result = await execTool!.execute("call-implicit-auto-default", { + command: "echo done", + }); + const resultDetails = result?.details as { status?: string } | undefined; + expect(resultDetails?.status).toBe("completed"); + }); + + it("fails closed when exec host=sandbox is requested without sandbox runtime", async () => { + const tools = createOpenClawCodingTools({ + config: {}, + sessionKey: "agent:main:main", + workspaceDir: "/tmp/test-main-fail-closed", + agentDir: "/tmp/agent-main-fail-closed", + }); + const execTool = tools.find((tool) => tool.name === "exec"); + expect(execTool).toBeDefined(); + await expect( + execTool!.execute("call-fail-closed", { + command: "echo done", + host: "sandbox", + }), + ).rejects.toThrow(/requires a sandbox runtime/); + }); + + it("should apply agent-specific exec host defaults over global defaults", async () => { + const cfg = createExecHostDefaultsConfig([ + { id: "main", execHost: "gateway" }, + { id: "helper" }, + ]); + + const mainTools = createOpenClawCodingTools({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir: "/tmp/test-main-exec-defaults", + agentDir: "/tmp/agent-main-exec-defaults", + }); + const mainExecTool = mainTools.find((tool) => tool.name === "exec"); + expect(mainExecTool).toBeDefined(); + const mainResult = await mainExecTool!.execute("call-main-default", { + command: "echo done", + yieldMs: 1000, + }); + const mainDetails = mainResult?.details as { status?: string } | undefined; + expect(mainDetails?.status).toBe("completed"); + await expect( + mainExecTool!.execute("call-main", { + command: "echo done", + host: "sandbox", + }), + ).rejects.toThrow("exec host not allowed"); + + const helperTools = createOpenClawCodingTools({ + config: cfg, + sessionKey: "agent:helper:main", + workspaceDir: "/tmp/test-helper-exec-defaults", + agentDir: "/tmp/agent-helper-exec-defaults", + }); + const helperExecTool = helperTools.find((tool) => tool.name === "exec"); + expect(helperExecTool).toBeDefined(); + const helperResult = await helperExecTool!.execute("call-helper-default", { + command: "echo done", + yieldMs: 1000, + }); + const helperDetails = helperResult?.details as { status?: string } | undefined; + expect(helperDetails?.status).toBe("completed"); + await expect( + helperExecTool!.execute("call-helper", { + command: "echo done", + host: "sandbox", + yieldMs: 1000, + }), + ).rejects.toThrow(/requires a sandbox runtime/); + }); + + it("applies explicit agentId exec defaults when sessionKey is opaque", async () => { + const cfg = createExecHostDefaultsConfig([{ id: "main", execHost: "gateway" }]); + + const tools = createOpenClawCodingTools({ + config: cfg, + agentId: "main", + sessionKey: "run-opaque-123", + workspaceDir: "/tmp/test-main-opaque-session", + agentDir: "/tmp/agent-main-opaque-session", + }); + const execTool = tools.find((tool) => tool.name === "exec"); + expect(execTool).toBeDefined(); + const result = await execTool!.execute("call-main-opaque-session", { + command: "echo done", + yieldMs: 1000, + }); + const details = result?.details as { status?: string } | undefined; + expect(details?.status).toBe("completed"); + }); +}); diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index 135b422d2c5..fdfe7bee15d 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; +import "./test-helpers/fast-bash-tools.js"; import "./test-helpers/fast-coding-tools.js"; import "./test-helpers/fast-openclaw-tools.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -129,34 +130,6 @@ describe("Agent-specific tool filtering", () => { }; } - function createExecHostDefaultsConfig( - agents: Array<{ id: string; execHost?: "auto" | "gateway" | "sandbox" }>, - ): OpenClawConfig { - return { - tools: { - exec: { - host: "auto", - security: "full", - ask: "off", - }, - }, - agents: { - list: agents.map((agent) => ({ - id: agent.id, - ...(agent.execHost - ? { - tools: { - exec: { - host: agent.execHost, - }, - }, - } - : {}), - })), - }, - }; - } - it("should apply global tool policy when no agent-specific policy exists", () => { const cfg = createMainAgentConfig({ tools: { @@ -647,145 +620,4 @@ describe("Agent-specific tool filtering", () => { expect(toolNames).not.toContain("exec"); expect(toolNames).not.toContain("write"); }); - - it("should run exec synchronously when process is denied", async () => { - const cfg: OpenClawConfig = { - tools: { - deny: ["process"], - exec: { - host: "gateway", - security: "full", - ask: "off", - }, - }, - }; - - const tools = createOpenClawCodingTools({ - config: cfg, - sessionKey: "agent:main:main", - workspaceDir: "/tmp/test-main", - agentDir: "/tmp/agent-main", - }); - const execTool = tools.find((tool) => tool.name === "exec"); - expect(execTool).toBeDefined(); - - const result = await execTool?.execute("call1", { - command: "echo done", - yieldMs: 10, - }); - - const resultDetails = result?.details as { status?: string } | undefined; - expect(resultDetails?.status).toBe("completed"); - }); - - it("routes implicit auto exec to gateway without a sandbox runtime", async () => { - const tools = createOpenClawCodingTools({ - config: { - tools: { - exec: { - security: "full", - ask: "off", - }, - }, - }, - sessionKey: "agent:main:main", - workspaceDir: "/tmp/test-main-implicit-gateway", - agentDir: "/tmp/agent-main-implicit-gateway", - }); - const execTool = tools.find((tool) => tool.name === "exec"); - expect(execTool).toBeDefined(); - - const result = await execTool!.execute("call-implicit-auto-default", { - command: "echo done", - }); - const resultDetails = result?.details as { status?: string } | undefined; - expect(resultDetails?.status).toBe("completed"); - }); - - it("fails closed when exec host=sandbox is requested without sandbox runtime", async () => { - const tools = createOpenClawCodingTools({ - config: {}, - sessionKey: "agent:main:main", - workspaceDir: "/tmp/test-main-fail-closed", - agentDir: "/tmp/agent-main-fail-closed", - }); - const execTool = tools.find((tool) => tool.name === "exec"); - expect(execTool).toBeDefined(); - await expect( - execTool!.execute("call-fail-closed", { - command: "echo done", - host: "sandbox", - }), - ).rejects.toThrow(/requires a sandbox runtime/); - }); - - it("should apply agent-specific exec host defaults over global defaults", async () => { - const cfg = createExecHostDefaultsConfig([ - { id: "main", execHost: "gateway" }, - { id: "helper" }, - ]); - - const mainTools = createOpenClawCodingTools({ - config: cfg, - sessionKey: "agent:main:main", - workspaceDir: "/tmp/test-main-exec-defaults", - agentDir: "/tmp/agent-main-exec-defaults", - }); - const mainExecTool = mainTools.find((tool) => tool.name === "exec"); - expect(mainExecTool).toBeDefined(); - const mainResult = await mainExecTool!.execute("call-main-default", { - command: "echo done", - yieldMs: 1000, - }); - const mainDetails = mainResult?.details as { status?: string } | undefined; - expect(mainDetails?.status).toBe("completed"); - await expect( - mainExecTool!.execute("call-main", { - command: "echo done", - host: "sandbox", - }), - ).rejects.toThrow("exec host not allowed"); - - const helperTools = createOpenClawCodingTools({ - config: cfg, - sessionKey: "agent:helper:main", - workspaceDir: "/tmp/test-helper-exec-defaults", - agentDir: "/tmp/agent-helper-exec-defaults", - }); - const helperExecTool = helperTools.find((tool) => tool.name === "exec"); - expect(helperExecTool).toBeDefined(); - const helperResult = await helperExecTool!.execute("call-helper-default", { - command: "echo done", - yieldMs: 1000, - }); - const helperDetails = helperResult?.details as { status?: string } | undefined; - expect(helperDetails?.status).toBe("completed"); - await expect( - helperExecTool!.execute("call-helper", { - command: "echo done", - host: "sandbox", - yieldMs: 1000, - }), - ).rejects.toThrow(/requires a sandbox runtime/); - }); - - it("applies explicit agentId exec defaults when sessionKey is opaque", async () => { - const cfg = createExecHostDefaultsConfig([{ id: "main", execHost: "gateway" }]); - - const tools = createOpenClawCodingTools({ - config: cfg, - agentId: "main", - sessionKey: "run-opaque-123", - workspaceDir: "/tmp/test-main-opaque-session", - agentDir: "/tmp/agent-main-opaque-session", - }); - const execTool = tools.find((tool) => tool.name === "exec"); - expect(execTool).toBeDefined(); - const result = await execTool!.execute("call-main-opaque-session", { - command: "echo done", - yieldMs: 1000, - }); - const details = result?.details as { status?: string } | undefined; - expect(details?.status).toBe("completed"); - }); }); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts index 02985f66218..fa0fabc556e 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import "./test-helpers/fast-bash-tools.js"; import "./test-helpers/fast-coding-tools.js"; import "./test-helpers/fast-openclaw-tools.js"; import { createOpenClawCodingTools } from "./pi-tools.js"; -const defaultTools = createOpenClawCodingTools({ senderIsOwner: true }); - describe("createOpenClawCodingTools", () => { it("preserves action enums in normalized schemas", () => { + const defaultTools = createOpenClawCodingTools({ senderIsOwner: true }); const toolNames = ["canvas", "nodes", "cron", "gateway", "message"]; const missingNames = toolNames.filter( (name) => !defaultTools.some((candidate) => candidate.name === name), @@ -56,6 +56,7 @@ describe("createOpenClawCodingTools", () => { } }); it("enforces apply_patch availability and canonical names across model/provider constraints", () => { + const defaultTools = createOpenClawCodingTools({ senderIsOwner: true }); expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true); expect(defaultTools.some((tool) => tool.name === "process")).toBe(true); expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-c.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-c.test.ts index 0a5460cf972..d864da8c9cf 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-c.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-c.test.ts @@ -8,6 +8,7 @@ import { GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, XAI_UNSUPPORTED_SCHEMA_KEYWORDS, } from "../plugin-sdk/provider-tools.js"; +import "./test-helpers/fast-bash-tools.js"; import "./test-helpers/fast-coding-tools.js"; import "./test-helpers/fast-openclaw-tools.js"; import { createOpenClawCodingTools } from "./pi-tools.js"; diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts index 7dac1b491cb..09e3a73acd4 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts @@ -2,13 +2,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import "./test-helpers/fast-bash-tools.js"; import "./test-helpers/fast-coding-tools.js"; import "./test-helpers/fast-openclaw-tools.js"; import { createOpenClawCodingTools } from "./pi-tools.js"; import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; import { createPiToolsSandboxContext } from "./test-helpers/pi-tools-sandbox-context.js"; -const defaultTools = createOpenClawCodingTools(); const tinyPngBuffer = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2f7z8AAAAASUVORK5CYII=", "base64", @@ -16,6 +16,7 @@ const tinyPngBuffer = Buffer.from( describe("createOpenClawCodingTools", () => { it("returns image-aware read metadata for images and text-only blocks for text files", async () => { + const defaultTools = createOpenClawCodingTools(); const readTool = defaultTools.find((tool) => tool.name === "read"); expect(readTool).toBeDefined(); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts index 1e7bc4b2d0e..9e49a2b1e6e 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import "./test-helpers/fast-bash-tools.js"; import "./test-helpers/fast-coding-tools.js"; import "./test-helpers/fast-openclaw-tools.js"; import { createOpenClawCodingTools } from "./pi-tools.js"; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index f6a2815ec85..268f56cabb9 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -13,12 +13,10 @@ import { import { resolveGatewayMessageChannel } from "../utils/message-channel.js"; import { resolveAgentConfig } from "./agent-scope.js"; import { createApplyPatchTool } from "./apply-patch.js"; -import { - createExecTool, - createProcessTool, - type ExecToolDefaults, - type ProcessToolDefaults, -} from "./bash-tools.js"; +import { describeExecTool, describeProcessTool } from "./bash-tools.descriptions.js"; +import type { ExecToolDefaults } from "./bash-tools.exec-types.js"; +import type { ProcessToolDefaults } from "./bash-tools.process.js"; +import { execSchema, processSchema } from "./bash-tools.schemas.js"; import { listChannelAgentTools } from "./channel-tools.js"; import { shouldSuppressManagedWebSearchTool } from "./codex-native-web-search.js"; import { resolveImageSanitizationLimits } from "./image-sanitization.js"; @@ -51,6 +49,10 @@ import { import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; import type { SandboxContext } from "./sandbox.js"; +import { + EXEC_TOOL_DISPLAY_SUMMARY, + PROCESS_TOOL_DISPLAY_SUMMARY, +} from "./tool-description-presets.js"; import { createToolFsPolicy, resolveToolFsConfig } from "./tool-fs-policy.js"; import { applyToolPolicyPipeline, @@ -71,6 +73,53 @@ function isOpenAIProvider(provider?: string) { const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]); +function createLazyExecTool(defaults?: ExecToolDefaults): AnyAgentTool { + let loadedTool: AnyAgentTool | undefined; + const loadTool = async () => { + if (!loadedTool) { + const { createExecTool } = await import("./bash-tools.js"); + loadedTool = createExecTool(defaults) as unknown as AnyAgentTool; + } + return loadedTool; + }; + + return { + name: "exec", + label: "exec", + displaySummary: EXEC_TOOL_DISPLAY_SUMMARY, + get description() { + return describeExecTool({ + agentId: defaults?.agentId, + hasCronTool: defaults?.hasCronTool === true, + }); + }, + parameters: execSchema, + execute: async (...args: Parameters) => + (await loadTool()).execute(...args), + } as AnyAgentTool; +} + +function createLazyProcessTool(defaults?: ProcessToolDefaults): AnyAgentTool { + let loadedTool: AnyAgentTool | undefined; + const loadTool = async () => { + if (!loadedTool) { + const { createProcessTool } = await import("./bash-tools.js"); + loadedTool = createProcessTool(defaults) as unknown as AnyAgentTool; + } + return loadedTool; + }; + + return { + name: "process", + label: "process", + displaySummary: PROCESS_TOOL_DISPLAY_SUMMARY, + description: describeProcessTool({ hasCronTool: defaults?.hasCronTool === true }), + parameters: processSchema, + execute: async (...args: Parameters) => + (await loadTool()).execute(...args), + } as AnyAgentTool; +} + function applyModelProviderToolPolicy( tools: AnyAgentTool[], params?: { @@ -411,7 +460,7 @@ export function createOpenClawCodingTools(options?: { return [tool]; }); const { cleanupMs: cleanupMsOverride, ...execDefaults } = options?.exec ?? {}; - const execTool = createExecTool({ + const execTool = createLazyExecTool({ ...execDefaults, host: options?.exec?.host ?? execConfig.host, security: options?.exec?.security ?? execConfig.security, @@ -450,7 +499,7 @@ export function createOpenClawCodingTools(options?: { } : undefined, }); - const processTool = createProcessTool({ + const processTool = createLazyProcessTool({ cleanupMs: cleanupMsOverride ?? execConfig.cleanupMs, scopeKey, }); diff --git a/src/agents/sandbox/fs-bridge.e2e-docker.test.ts b/src/agents/sandbox/fs-bridge.e2e-docker.test.ts index 62a064b49f5..f2aa451593f 100644 --- a/src/agents/sandbox/fs-bridge.e2e-docker.test.ts +++ b/src/agents/sandbox/fs-bridge.e2e-docker.test.ts @@ -1,23 +1,60 @@ +import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { DEFAULT_SANDBOX_IMAGE } from "./constants.js"; -import { buildSandboxCreateArgs, execDocker, execDockerRaw } from "./docker.js"; -import { createSandboxFsBridge } from "./fs-bridge.js"; -import { createSandboxTestContext } from "./test-fixtures.js"; -import { appendWorkspaceMountArgs } from "./workspace-mounts.js"; + +type DockerExecResult = { + stdout: string; + stderr: string; + code: number; +}; + +async function execDockerRawForTest(args: string[]): Promise { + return await new Promise((resolve) => { + const child = spawn("docker", args, { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", () => { + resolve({ stdout: "", stderr: "", code: 1 }); + }); + child.on("close", (code) => { + resolve({ stdout, stderr, code: code ?? 0 }); + }); + }); +} + +async function execDockerForTest(args: string[]): Promise { + const result = await execDockerRawForTest(args); + if (result.code !== 0) { + const message = result.stderr.trim() || result.stdout.trim() || `docker ${args.join(" ")}`; + throw new Error(message); + } +} async function sandboxImageReady(): Promise { try { - const dockerVersion = await execDockerRaw(["version"], { allowFailure: true }); + const dockerVersion = await execDockerRawForTest(["version"]); if (dockerVersion.code !== 0) { return false; } - const pythonCheck = await execDockerRaw( - ["run", "--rm", "--entrypoint", "python3", DEFAULT_SANDBOX_IMAGE, "--version"], - { allowFailure: true }, - ); + const pythonCheck = await execDockerRawForTest([ + "run", + "--rm", + "--entrypoint", + "python3", + DEFAULT_SANDBOX_IMAGE, + "--version", + ]); return pythonCheck.code === 0; } catch { return false; @@ -40,6 +77,18 @@ describe("sandbox fs bridge docker e2e", () => { const containerName = `openclaw-fsbridge-${suffix}`.slice(0, 63); try { + const [ + { buildSandboxCreateArgs }, + { createSandboxFsBridge }, + { createSandboxTestContext }, + { appendWorkspaceMountArgs }, + ] = await Promise.all([ + import("./docker.js"), + import("./fs-bridge.js"), + import("./test-fixtures.js"), + import("./workspace-mounts.js"), + ]); + const sandbox = createSandboxTestContext({ overrides: { workspaceDir, @@ -71,8 +120,8 @@ describe("sandbox fs bridge docker e2e", () => { }); createArgs.push(sandbox.docker.image, "sleep", "infinity"); - await execDocker(createArgs); - await execDocker(["start", containerName]); + await execDockerForTest(createArgs); + await execDockerForTest(["start", containerName]); const bridge = createSandboxFsBridge({ sandbox }); await bridge.writeFile({ filePath: "nested/hello.txt", data: "from-docker" }); @@ -81,7 +130,7 @@ describe("sandbox fs bridge docker e2e", () => { fs.readFile(path.join(workspaceDir, "nested", "hello.txt"), "utf8"), ).resolves.toBe("from-docker"); } finally { - await execDocker(["rm", "-f", containerName], { allowFailure: true }); + await execDockerRawForTest(["rm", "-f", containerName]); await fs.rm(stateDir, { recursive: true, force: true }); } }, diff --git a/src/agents/subagent-list.test.ts b/src/agents/subagent-list.test.ts index 731b8f412d8..fd401ef9971 100644 --- a/src/agents/subagent-list.test.ts +++ b/src/agents/subagent-list.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { updateSessionStore } from "../config/sessions.js"; +import { updateSessionStore } from "../config/sessions/store.js"; import { buildSubagentList } from "./subagent-list.js"; import { addSubagentRunForTests, diff --git a/src/agents/test-helpers/fast-bash-tools.ts b/src/agents/test-helpers/fast-bash-tools.ts new file mode 100644 index 00000000000..155ba925b0b --- /dev/null +++ b/src/agents/test-helpers/fast-bash-tools.ts @@ -0,0 +1,7 @@ +import { vi } from "vitest"; +import { stubTool } from "./fast-tool-stubs.js"; + +vi.mock("../bash-tools.js", () => ({ + createExecTool: () => stubTool("exec"), + createProcessTool: () => stubTool("process"), +})); diff --git a/src/agents/test-helpers/fast-tool-stubs.ts b/src/agents/test-helpers/fast-tool-stubs.ts index d86eede29f0..7947203f377 100644 --- a/src/agents/test-helpers/fast-tool-stubs.ts +++ b/src/agents/test-helpers/fast-tool-stubs.ts @@ -32,12 +32,8 @@ vi.mock("../tools/web-tools.js", () => ({ createWebFetchTool: () => null, })); -vi.mock("../../plugins/tools.js", async () => { - const mod = - await vi.importActual("../../plugins/tools.js"); - return { - ...mod, - resolvePluginTools: () => [], - getPluginToolMeta: () => undefined, - }; -}); +vi.mock("../../plugins/tools.js", () => ({ + copyPluginToolMeta: (_from: unknown, to: unknown) => to, + getPluginToolMeta: () => undefined, + resolvePluginTools: () => [], +})); diff --git a/src/agents/tools/sessions-announce-target.ts b/src/agents/tools/sessions-announce-target.ts index c05a8a8f2ca..401cdc40c61 100644 --- a/src/agents/tools/sessions-announce-target.ts +++ b/src/agents/tools/sessions-announce-target.ts @@ -1,11 +1,16 @@ import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; -import { callGateway } from "../../gateway/call.js"; +import type { CallGatewayOptions } from "../../gateway/call.js"; import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js"; import { normalizeOptionalStringifiedId } from "../../shared/string-coerce.js"; -import { SessionListRow } from "./sessions-helpers.js"; +import type { SessionListRow } from "./sessions-helpers.js"; import type { AnnounceTarget } from "./sessions-send-helpers.js"; import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js"; +async function callGatewayLazy(opts: CallGatewayOptions): Promise { + const { callGateway } = await import("../../gateway/call.js"); + return callGateway(opts); +} + export async function resolveAnnounceTarget(params: { sessionKey: string; displayKey: string; @@ -27,7 +32,7 @@ export async function resolveAnnounceTarget(params: { } try { - const list = await callGateway<{ sessions: Array }>({ + const list = await callGatewayLazy<{ sessions: Array }>({ method: "sessions.list", params: { includeGlobal: true, diff --git a/src/agents/tools/sessions-send-tool.a2a.ts b/src/agents/tools/sessions-send-tool.a2a.ts index f9d2e9499ed..94af01c06a2 100644 --- a/src/agents/tools/sessions-send-tool.a2a.ts +++ b/src/agents/tools/sessions-send-tool.a2a.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { callGateway } from "../../gateway/call.js"; +import type { CallGatewayOptions } from "../../gateway/call.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; @@ -16,10 +16,13 @@ import { const log = createSubsystemLogger("agents/sessions-send"); -type GatewayCaller = typeof callGateway; +type GatewayCaller = (opts: CallGatewayOptions) => Promise; const defaultSessionsSendA2ADeps = { - callGateway, + callGateway: async (opts: CallGatewayOptions): Promise => { + const { callGateway } = await import("../../gateway/call.js"); + return callGateway(opts); + }, }; let sessionsSendA2ADeps: {