mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
perf: reduce agents test import overhead
This commit is contained in:
@@ -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>;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
71
src/agents/bash-tools.schemas.ts
Normal file
71
src/agents/bash-tools.schemas.ts
Normal 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,
|
||||
}),
|
||||
),
|
||||
});
|
||||
169
src/agents/command/attempt-execution.helpers.ts
Normal file
169
src/agents/command/attempt-execution.helpers.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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"]>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
320
src/agents/models-config.providers.secret-helpers.ts
Normal file
320
src/agents/models-config.providers.secret-helpers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
182
src/agents/pi-tools-agent-config.exec.test.ts
Normal file
182
src/agents/pi-tools-agent-config.exec.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
7
src/agents/test-helpers/fast-bash-tools.ts
Normal file
7
src/agents/test-helpers/fast-bash-tools.ts
Normal 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"),
|
||||
}));
|
||||
@@ -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: () => [],
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user