Files
openclaw/src/agents/code-mode-runtime.ts
Peter Steinberger 8ad4e858fd fix(ci): repair red main — align QA evidence timeout guard, drop stale code-mode symbols
The qa-profile-evidence timeout was deliberately raised to 90m (28e09a04d3)
without its workflow guard; code-mode-repair.ts grew a local copy of
CodeModeFailurePhase stranding the runtime re-export, and the matrix test
kept an unused validateQaEvidenceSummaryJson import.
2026-07-29 00:48:09 -07:00

661 lines
22 KiB
TypeScript

import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { uniqueValues } from "@openclaw/normalization-core/string-normalization";
import { parse, tokenizer } from "acorn";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createLazyPromiseLoader } from "../shared/lazy-runtime.js";
import { clampNumber } from "../utils.js";
import { resolveAgentConfig } from "./agent-scope-config.js";
import { toCodeModeJsonSafe } from "./code-mode-json.js";
import type { CodeModeNamespaceRuntime } from "./code-mode-namespaces.js";
import {
CODE_MODE_SHELL_SOURCE_ERROR,
isShellLikeCodeModeSource,
} from "./code-mode-shell-source.js";
import type { CodeModeFailurePhase, CodeModeWorkerThreadResult } from "./code-mode-worker-types.js";
import type { ToolSearchConfig, ToolSearchToolContext } from "./tool-search.js";
import { asToolParamsRecord, ToolInputError } from "./tools/common.js";
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;
const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
const DEFAULT_MAX_SNAPSHOT_BYTES = 10 * 1024 * 1024;
const DEFAULT_MAX_PENDING_TOOL_CALLS = 16;
const DEFAULT_SNAPSHOT_TTL_SECONDS = 900;
const DEFAULT_SEARCH_LIMIT = 8;
const DEFAULT_MAX_SEARCH_LIMIT = 50;
export const CODE_MODE_WORKER_WATCHDOG_GRACE_MS = 2_000;
export const DEFAULT_HEADLESS_WALL_CLOCK_MS = 30_000;
// Cron script payloads persist caps of 900 seconds and 200 tool calls.
// The shared executor must not silently lower those accepted job limits.
export const MAX_HEADLESS_WALL_CLOCK_MS = 900_000;
export const DEFAULT_HEADLESS_TOOL_CALLS = 5;
export const MAX_HEADLESS_TOOL_CALLS = 200;
export type CodeModeLanguage = "javascript" | "typescript";
/** Resolved Code Mode runtime limits and visible language options. */
export type CodeModeConfig = {
/** Master switch tier: true/false, or "auto" (engage per model catalog flag). */
enabled: boolean | "auto";
runtime: "quickjs-wasi";
mode: "only";
languages: CodeModeLanguage[];
timeoutMs: number;
memoryLimitBytes: number;
maxOutputBytes: number;
maxSnapshotBytes: number;
maxPendingToolCalls: number;
snapshotTtlSeconds: number;
searchDefaultLimit: number;
maxSearchLimit: number;
};
export type {
CodeModeSettlementMode,
PendingBridgeRequest,
SettledBridgeRequest,
} from "./code-mode-worker-types.js";
export type CodeModeFailureCode =
| "aborted"
| "invalid_input"
| "runtime_unavailable"
| "timeout"
| "output_limit_exceeded"
| "snapshot_limit_exceeded"
| "internal_error";
export type CodeModeHeadlessResult =
| {
status: "completed";
value: unknown;
output: unknown[];
toolCallCount: number;
}
| {
status: "failed";
code: CodeModeFailureCode | "tool_budget_exceeded";
error: string;
output: unknown[];
toolCallCount: number;
};
export type CodeModeWorkerResult =
| Extract<CodeModeWorkerThreadResult, { status: "completed" | "waiting" }>
| {
status: "failed";
error: string;
code: CodeModeFailureCode;
failurePhase: CodeModeFailurePhase;
bridgeDispatchStarted: boolean;
output: unknown[];
};
const typescriptRuntimeLoader = createLazyPromiseLoader(() => import("typescript"), {
cacheRejections: true,
});
let typescriptRuntimeForTest:
| typeof import("typescript")
| Promise<typeof import("typescript")>
| null = null;
function normalizeCodeModeRawConfig(value: unknown): Record<string, unknown> | undefined {
const codeMode = value;
if (codeMode === true) {
return { enabled: true };
}
if (codeMode === false) {
return { enabled: false };
}
if (codeMode === "auto") {
return { enabled: "auto" };
}
return isRecord(codeMode) ? codeMode : undefined;
}
function readCodeModeRawConfig(config?: OpenClawConfig, agentId?: string): Record<string, unknown> {
const tools = isRecord(config?.tools) ? config.tools : undefined;
const globalRaw = normalizeCodeModeRawConfig(tools?.codeMode) ?? {};
const agentRaw =
config && agentId
? normalizeCodeModeRawConfig(resolveAgentConfig(config, agentId)?.tools?.codeMode)
: undefined;
return agentRaw ? { ...globalRaw, ...agentRaw } : globalRaw;
}
function readEnabled(value: unknown): boolean | "auto" {
// Shipped default is "auto": code mode engages only for catalog-preferred
// models, so unevaluated models keep normal tool exposure by construction.
return typeof value === "boolean" || value === "auto" ? value : "auto";
}
export function readPositiveInteger(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
}
function readLanguages(value: unknown): CodeModeLanguage[] {
if (!Array.isArray(value)) {
return ["javascript", "typescript"];
}
const languages = value.filter(
(entry): entry is CodeModeLanguage => entry === "javascript" || entry === "typescript",
);
return languages.length > 0 ? uniqueValues(languages) : ["javascript", "typescript"];
}
/** Resolves Code Mode runtime limits and language support from config. */
export function resolveCodeModeConfig(config?: OpenClawConfig, agentId?: string): CodeModeConfig {
const raw = readCodeModeRawConfig(config, agentId);
const maxSearchLimit = clampNumber(
readPositiveInteger(raw.maxSearchLimit, DEFAULT_MAX_SEARCH_LIMIT),
1,
DEFAULT_MAX_SEARCH_LIMIT,
);
return {
enabled: readEnabled(raw.enabled),
runtime: "quickjs-wasi",
mode: "only",
languages: readLanguages(raw.languages),
timeoutMs: clampNumber(readPositiveInteger(raw.timeoutMs, DEFAULT_TIMEOUT_MS), 100, 60_000),
memoryLimitBytes: clampNumber(
readPositiveInteger(raw.memoryLimitBytes, DEFAULT_MEMORY_LIMIT_BYTES),
1024 * 1024,
1024 * 1024 * 1024,
),
maxOutputBytes: clampNumber(
readPositiveInteger(raw.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES),
1024,
10 * 1024 * 1024,
),
maxSnapshotBytes: clampNumber(
readPositiveInteger(raw.maxSnapshotBytes, DEFAULT_MAX_SNAPSHOT_BYTES),
1024,
256 * 1024 * 1024,
),
maxPendingToolCalls: clampNumber(
readPositiveInteger(raw.maxPendingToolCalls, DEFAULT_MAX_PENDING_TOOL_CALLS),
1,
128,
),
snapshotTtlSeconds: clampNumber(
readPositiveInteger(raw.snapshotTtlSeconds, DEFAULT_SNAPSHOT_TTL_SECONDS),
1,
24 * 60 * 60,
),
searchDefaultLimit: clampNumber(
readPositiveInteger(raw.searchDefaultLimit, DEFAULT_SEARCH_LIMIT),
1,
maxSearchLimit,
),
maxSearchLimit,
};
}
/**
* Resolves the master switch against one model's catalog capability flag.
* `true`/`false` are absolute; `"auto"` engages only for models whose catalog
* compat declares `codeMode: "preferred"`. This gates the model-facing tool
* surface only; runs that route to a provider-native harness (for example the
* default OpenAI Codex surface) never reach this embedded-runtime gate.
*/
export function isCodeModeEngagedForModel(
config: Pick<CodeModeConfig, "enabled">,
model: { compat?: unknown } | undefined,
): boolean {
if (config.enabled !== "auto") {
return config.enabled;
}
const compat =
model?.compat && typeof model.compat === "object"
? (model.compat as { codeMode?: unknown })
: undefined;
return compat?.codeMode === "preferred";
}
export function toToolSearchConfig(config: CodeModeConfig): ToolSearchConfig {
return {
enabled: true,
mode: "tools",
codeTimeoutMs: config.timeoutMs,
searchDefaultLimit: config.searchDefaultLimit,
maxSearchLimit: config.maxSearchLimit,
};
}
export function resolveCodeModeHeadlessConfig(
ctx: ToolSearchToolContext,
overrides?: Partial<
Pick<
CodeModeConfig,
| "timeoutMs"
| "memoryLimitBytes"
| "maxOutputBytes"
| "maxSnapshotBytes"
| "maxPendingToolCalls"
>
>,
): CodeModeConfig {
const base = resolveCodeModeConfig(ctx.runtimeConfig ?? ctx.config, ctx.agentId);
return {
...base,
timeoutMs: clampNumber(readPositiveInteger(overrides?.timeoutMs, base.timeoutMs), 100, 60_000),
memoryLimitBytes: clampNumber(
readPositiveInteger(overrides?.memoryLimitBytes, base.memoryLimitBytes),
1024 * 1024,
1024 * 1024 * 1024,
),
maxOutputBytes: clampNumber(
readPositiveInteger(overrides?.maxOutputBytes, base.maxOutputBytes),
1024,
10 * 1024 * 1024,
),
maxSnapshotBytes: clampNumber(
readPositiveInteger(overrides?.maxSnapshotBytes, base.maxSnapshotBytes),
1024,
256 * 1024 * 1024,
),
maxPendingToolCalls: clampNumber(
readPositiveInteger(overrides?.maxPendingToolCalls, base.maxPendingToolCalls),
1,
128,
),
};
}
function jsonByteLength(value: unknown): number {
return Buffer.byteLength(JSON.stringify(toCodeModeJsonSafe(value)) ?? "null", "utf8");
}
class CodeModeLimitError extends ToolInputError {
readonly code: Extract<CodeModeFailureCode, "output_limit_exceeded" | "snapshot_limit_exceeded">;
constructor(
code: Extract<CodeModeFailureCode, "output_limit_exceeded" | "snapshot_limit_exceeded">,
message: string,
) {
super(message);
this.name = "CodeModeLimitError";
this.code = code;
}
}
function isRuntimeInterruptedError(error: unknown): boolean {
return errorMessage(error) === "interrupted";
}
export function codeModeFailureCode(error: unknown): CodeModeFailureCode {
if (error instanceof CodeModeLimitError) {
return error.code;
}
if (isRuntimeInterruptedError(error)) {
return "timeout";
}
return error instanceof ToolInputError ? "invalid_input" : "internal_error";
}
export function codeModeFailureMessage(error: unknown): string {
return isRuntimeInterruptedError(error) ? "code mode timeout exceeded" : errorMessage(error);
}
export function enforceOutputLimit(output: unknown[], config: CodeModeConfig): void {
if (jsonByteLength(output) > config.maxOutputBytes) {
throw new CodeModeLimitError("output_limit_exceeded", "code mode output limit exceeded");
}
}
export function enforceResultLimit(params: {
output: unknown[];
value?: unknown;
config: CodeModeConfig;
}): void {
const serializedOutputBytes = jsonByteLength(params.output);
if (serializedOutputBytes > params.config.maxOutputBytes) {
throw new CodeModeLimitError("output_limit_exceeded", "code mode output limit exceeded");
}
const outputBytes = params.output.length > 0 ? serializedOutputBytes : 0;
if (
params.value !== undefined &&
outputBytes + jsonByteLength(params.value) > params.config.maxOutputBytes
) {
throw new CodeModeLimitError("output_limit_exceeded", "code mode output limit exceeded");
}
}
export function readCode(args: unknown): {
code: string;
language?: CodeModeLanguage;
restartSafe: boolean;
} {
const params = asToolParamsRecord(args);
const codeParam = params.code;
const commandParam = params.command;
if (
typeof codeParam === "string" &&
typeof commandParam === "string" &&
codeParam !== commandParam
) {
throw new ToolInputError("code and command must match when both are provided.");
}
const code = typeof commandParam === "string" ? commandParam : codeParam;
if (typeof code !== "string" || !code.trim()) {
throw new ToolInputError("code or command must be a non-empty string.");
}
const language = params.language;
if (language !== undefined && language !== "javascript" && language !== "typescript") {
throw new ToolInputError("language must be javascript or typescript.");
}
const restartSafe = params.restartSafe;
if (restartSafe !== undefined && typeof restartSafe !== "boolean") {
throw new ToolInputError("restartSafe must be a boolean.");
}
return { code, language, restartSafe: restartSafe === true };
}
export function readRunId(args: unknown): string {
const params = asToolParamsRecord(args);
const runId = params.runId ?? params.run_id;
if (typeof runId !== "string" || !runId.trim()) {
throw new ToolInputError("runId must be a non-empty string.");
}
return runId.trim();
}
function maskCodeLiteralsAndComments(
code: string,
typescriptRuntime?: typeof import("typescript"),
): string {
let masked = code.split("");
const maskRange = (start: number, end: number, offset = 0) => {
for (
let index = Math.max(start - offset, 0);
index < Math.min(end - offset, masked.length);
index += 1
) {
if (masked[index] !== "\n" && masked[index] !== "\r") {
masked[index] = " ";
}
}
};
try {
const prefix = "(async () => {\n";
parse(`${prefix}${code}\n})`, {
ecmaVersion: "latest",
onComment: (_isBlock, _text, start, end) => maskRange(start, end, prefix.length),
onToken: (token) => {
// Parse in the real async guest context: standalone tokenization can
// mistake executable division for a regex after contextual keywords.
if (
token.type.label === "string" ||
token.type.label === "regexp" ||
token.type.label === "template"
) {
maskRange(token.start, token.end, prefix.length);
}
},
});
return masked.join("");
} catch {
// Parser and tokenizer offsets are UTF-16 code units, not Unicode points.
masked = code.split("");
if (typescriptRuntime) {
try {
const sourceFile = typescriptRuntime.createSourceFile(
"code-mode.ts",
code,
typescriptRuntime.ScriptTarget.ES2022,
true,
typescriptRuntime.ScriptKind.TS,
);
const visit = (node: import("typescript").Node) => {
typescriptRuntime.forEachLeadingCommentRange(code, node.getFullStart(), (start, end) =>
maskRange(start, end),
);
typescriptRuntime.forEachTrailingCommentRange(code, node.getEnd(), (start, end) =>
maskRange(start, end),
);
if (
typescriptRuntime.isStringLiteralLike(node) ||
typescriptRuntime.isRegularExpressionLiteral(node) ||
typescriptRuntime.isTemplateHead(node) ||
typescriptRuntime.isTemplateMiddle(node) ||
typescriptRuntime.isTemplateTail(node)
) {
maskRange(node.getStart(sourceFile), node.getEnd());
}
typescriptRuntime.forEachChild(node, visit);
};
visit(sourceFile);
return masked.join("");
} catch {
// A failed TypeScript parse must never expose a partially masked scan.
return code;
}
}
// Malformed JavaScript needs a conservative lexical pass: never trust a
// context-free regexp token to hide executable module access.
try {
for (const token of tokenizer(code, {
ecmaVersion: "latest",
onComment: (_isBlock, _text, start, end) => maskRange(start, end),
})) {
if (token.type.label === "string" || token.type.label === "template") {
maskRange(token.start, token.end);
}
}
return masked.join("");
} catch {
// Never inspect partially masked input after a tokenizer failure.
return code;
}
}
}
function isModuleLoaderCallee(callee: import("acorn").Expression | import("acorn").Super): boolean {
if (callee.type === "ParenthesizedExpression") {
return isModuleLoaderCallee(callee.expression);
}
if (callee.type === "ChainExpression") {
return isModuleLoaderCallee(callee.expression);
}
if (callee.type === "SequenceExpression") {
const expression = callee.expressions[callee.expressions.length - 1];
return expression !== undefined && isModuleLoaderCallee(expression);
}
return callee.type === "Identifier" && callee.name === "require";
}
function containsModuleAccess(node: import("acorn").AnyNode): boolean {
if (
node.type === "ImportDeclaration" ||
node.type === "ImportExpression" ||
(node.type === "MetaProperty" && node.meta.name === "import") ||
(node.type === "CallExpression" && isModuleLoaderCallee(node.callee))
) {
return true;
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const child of value) {
if (
child !== null &&
typeof child === "object" &&
"type" in child &&
typeof child.type === "string" &&
containsModuleAccess(child as import("acorn").AnyNode)
) {
return true;
}
}
continue;
}
if (
value !== null &&
typeof value === "object" &&
"type" in value &&
typeof value.type === "string" &&
containsModuleAccess(value as import("acorn").AnyNode)
) {
return true;
}
}
return false;
}
function typeScriptContainsModuleAccess(code: string, ts: typeof import("typescript")): boolean {
const source = ts.createSourceFile(
"code-mode.ts",
code,
ts.ScriptTarget.ES2022,
true,
ts.ScriptKind.TS,
);
const isLoaderCallee = (expression: import("typescript").Expression): boolean => {
if (ts.isParenthesizedExpression(expression)) {
return isLoaderCallee(expression.expression);
}
if (
ts.isBinaryExpression(expression) &&
expression.operatorToken.kind === ts.SyntaxKind.CommaToken
) {
return isLoaderCallee(expression.right);
}
return ts.isIdentifier(expression) && expression.text === "require";
};
const visit = (node: import("typescript").Node): boolean => {
if (
ts.isImportDeclaration(node) ||
ts.isImportEqualsDeclaration(node) ||
(ts.isMetaProperty(node) && node.keywordToken === ts.SyntaxKind.ImportKeyword) ||
(ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword || isLoaderCallee(node.expression)))
) {
return true;
}
return ts.forEachChild(node, (child) => (visit(child) ? true : undefined)) === true;
};
return visit(source);
}
function rejectsModuleAccess(
code: string,
typescriptRuntime?: typeof import("typescript"),
): boolean {
try {
const source = parse(`(async () => {\n${code}\n})`, {
ecmaVersion: "latest",
});
// The WASI guest has no host module loader. Only executable module syntax
// belongs in this early check; ordinary guest methods are not capabilities.
return containsModuleAccess(source);
} catch {
if (typescriptRuntime) {
try {
return typeScriptContainsModuleAccess(code, typescriptRuntime);
} catch {
// Keep malformed input on the conservative lexical fallback.
}
}
}
const source = maskCodeLiteralsAndComments(code, typescriptRuntime);
return /\bimport\b\s*(?:\.|\(|["'`{*]|\w)|\brequire\b\s*\(/u.test(source);
}
async function loadTypeScriptRuntime(): Promise<typeof import("typescript")> {
if (typescriptRuntimeForTest) {
return await typescriptRuntimeForTest;
}
return await typescriptRuntimeLoader.load();
}
export async function prepareSource(input: {
code: string;
language?: CodeModeLanguage;
config: CodeModeConfig;
}): Promise<string> {
const language = input.language ?? "javascript";
if (!input.config.languages.includes(language)) {
throw new ToolInputError(`code mode ${language} input is disabled.`);
}
if (language === "javascript") {
if (rejectsModuleAccess(input.code)) {
throw new ToolInputError("code mode module access is disabled.");
}
if (isShellLikeCodeModeSource(input.code)) {
throw new ToolInputError(CODE_MODE_SHELL_SOURCE_ERROR);
}
return input.code;
}
const ts = await loadTypeScriptRuntime();
if (rejectsModuleAccess(input.code, ts)) {
throw new ToolInputError("code mode module access is disabled.");
}
const transformed = ts.transpileModule(input.code, {
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext,
importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove,
sourceMap: false,
},
reportDiagnostics: true,
});
const diagnostics = transformed.diagnostics ?? [];
if (diagnostics.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)) {
const message = diagnostics
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"))
.join("\n");
throw new ToolInputError(`typescript transform failed: ${message}`);
}
if (rejectsModuleAccess(transformed.outputText, ts)) {
throw new ToolInputError("code mode module access is disabled.");
}
if (
isShellLikeCodeModeSource(input.code, transformed.outputText) ||
isShellLikeCodeModeSource(transformed.outputText)
) {
throw new ToolInputError(CODE_MODE_SHELL_SOURCE_ERROR);
}
return transformed.outputText;
}
export function errorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message || String(error);
}
return String(error);
}
export function createCodeModeApiFilesForRun(
namespaceRuntime: CodeModeNamespaceRuntime,
swarmEnabled: boolean,
) {
const { apiFiles: files } = namespaceRuntime;
return swarmEnabled ? files : files.filter((file) => file.path !== "agents.d.ts");
}
export function enforceSnapshotPayloadLimits(params: {
snapshotBytes: Uint8Array;
config: CodeModeConfig;
output: unknown[];
}) {
if (params.snapshotBytes.byteLength > params.config.maxSnapshotBytes) {
throw new CodeModeLimitError("snapshot_limit_exceeded", "code mode snapshot limit exceeded");
}
enforceOutputLimit(params.output, params.config);
}
export const codeModeRuntimeTesting = {
getTypescriptRuntimePromise: (): Promise<typeof import("typescript")> | null =>
typescriptRuntimeLoader.peek() ?? null,
setTypescriptRuntimeForTest: (
runtime: typeof import("typescript") | Promise<typeof import("typescript")> | null,
) => {
typescriptRuntimeForTest = runtime;
},
};