perf: reduce agents test import overhead

This commit is contained in:
Peter Steinberger
2026-04-13 01:26:20 +01:00
parent 4c8337f27b
commit 5b2ae49107
31 changed files with 998 additions and 865 deletions

View File

@@ -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<typeof ensureAuthProfileStore>;

View File

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

View File

@@ -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) {

View File

@@ -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,
}),
),
});

View File

@@ -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<boolean> {
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<string, unknown> | null;
if (
rec?.type === "message" &&
(rec.message as Record<string, unknown> | 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;
},
};
}

View File

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

View File

@@ -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<boolean> {
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<string, unknown> | null;
if (
rec?.type === "message" &&
(rec.message as Record<string, unknown> | undefined)?.role === "assistant"
) {
return true;
}
}
return false;
} finally {
await fh.close();
}
} catch {
return false;
}
}
export type PersistSessionEntryParams = {
sessionStore: Record<string, SessionEntry>;
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,

View File

@@ -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: {

View File

@@ -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`;

View File

@@ -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", () => {

View File

@@ -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(),

View File

@@ -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", () => ({

View File

@@ -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<OpenClawConfig["models"]>;

View File

@@ -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";

View File

@@ -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<OpenClawConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[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, NonNullable<ProviderConfig["headers"]>[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<string>;
}): 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<string>;
}): 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<string>;
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,
};
}

View File

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

View File

@@ -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<OpenClawConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[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<typeof ensureAuthProfileStore>
| (() => ReturnType<typeof ensureAuthProfileStore>);
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, NonNullable<ProviderConfig["headers"]>[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<typeof ensureAuthProfileStore>["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<typeof ensureAuthProfileStore>;
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<string>;
}): 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<string>;
}): 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<string>;
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,

View File

@@ -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<ExecApprovalReplyModule> | undefined;
let hookRunnerGlobalModulePromise: Promise<HookRunnerGlobalModule> | undefined;
let mediaParseModulePromise: Promise<MediaParseModule> | undefined;
let beforeToolCallModulePromise: Promise<BeforeToolCallModule> | undefined;
function loadExecApprovalReply(): Promise<ExecApprovalReplyModule> {
execApprovalReplyModulePromise ??= import("../infra/exec-approval-reply.js");
return execApprovalReplyModulePromise;
}
function loadHookRunnerGlobal(): Promise<HookRunnerGlobalModule> {
hookRunnerGlobalModulePromise ??= import("../plugins/hook-runner-global.js");
return hookRunnerGlobalModulePromise;
}
function loadMediaParse(): Promise<MediaParseModule> {
mediaParseModulePromise ??= import("../media/parse.js");
return mediaParseModulePromise;
}
function loadBeforeToolCall(): Promise<BeforeToolCallModule> {
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<string[]> {
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<string, unknown>)
: {};
const adjustedArgs = consumeAdjustedParamsForToolCall(toolCallId, runId);
const afterToolCallArgs =
adjustedArgs && typeof adjustedArgs === "object"
? (adjustedArgs as Record<string, unknown>)
: 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<string, unknown>)
: startArgs;
const durationMs = startData?.startTime != null ? Date.now() - startData.startTime : undefined;
const hookEvent: PluginHookAfterToolCallEvent = {
toolName,

View File

@@ -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");
});
});

View File

@@ -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");
});
});

View File

@@ -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);

View File

@@ -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";

View File

@@ -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();

View File

@@ -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";

View File

@@ -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<AnyAgentTool["execute"]>) =>
(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<AnyAgentTool["execute"]>) =>
(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,
});

View File

@@ -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<DockerExecResult> {
return await new Promise<DockerExecResult>((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<void> {
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<boolean> {
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 });
}
},

View File

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

View File

@@ -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"),
}));

View File

@@ -32,12 +32,8 @@ vi.mock("../tools/web-tools.js", () => ({
createWebFetchTool: () => null,
}));
vi.mock("../../plugins/tools.js", async () => {
const mod =
await vi.importActual<typeof import("../../plugins/tools.js")>("../../plugins/tools.js");
return {
...mod,
resolvePluginTools: () => [],
getPluginToolMeta: () => undefined,
};
});
vi.mock("../../plugins/tools.js", () => ({
copyPluginToolMeta: (_from: unknown, to: unknown) => to,
getPluginToolMeta: () => undefined,
resolvePluginTools: () => [],
}));

View File

@@ -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<T = unknown>(opts: CallGatewayOptions): Promise<T> {
const { callGateway } = await import("../../gateway/call.js");
return callGateway<T>(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<SessionListRow> }>({
const list = await callGatewayLazy<{ sessions: Array<SessionListRow> }>({
method: "sessions.list",
params: {
includeGlobal: true,

View File

@@ -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 = <T = unknown>(opts: CallGatewayOptions) => Promise<T>;
const defaultSessionsSendA2ADeps = {
callGateway,
callGateway: async <T = unknown>(opts: CallGatewayOptions): Promise<T> => {
const { callGateway } = await import("../../gateway/call.js");
return callGateway<T>(opts);
},
};
let sessionsSendA2ADeps: {