mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
1 Commits
v2026.6.1-
...
feat/code-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2f893c14a |
34
packages/code-mode-runtime/package.json
Normal file
34
packages/code-mode-runtime/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@openclaw/code-mode-runtime",
|
||||
"version": "0.0.0-private",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.mts",
|
||||
"import": "./dist/types.mjs",
|
||||
"default": "./dist/types.mjs"
|
||||
},
|
||||
"./worker": {
|
||||
"types": "./dist/worker.d.mts",
|
||||
"import": "./dist/worker.mjs",
|
||||
"default": "./dist/worker.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown src/index.ts src/types.ts src/worker.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"quickjs-wasi": "3.0.0"
|
||||
}
|
||||
}
|
||||
18
packages/code-mode-runtime/src/index.ts
Normal file
18
packages/code-mode-runtime/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export type {
|
||||
CodeModeBridgeMethod,
|
||||
CodeModeFailureCode,
|
||||
CodeModePendingBridgeRequest,
|
||||
CodeModeRuntimeConfig,
|
||||
CodeModeSettledBridgeRequest,
|
||||
CodeModeWorkerInput,
|
||||
CodeModeWorkerResult,
|
||||
} from "./types.js";
|
||||
|
||||
export function resolveCodeModeRuntimeWorkerUrl(currentModuleUrl = import.meta.url): URL {
|
||||
const currentPath = fileURLToPath(currentModuleUrl);
|
||||
const extension = path.extname(currentPath) || ".js";
|
||||
return new URL(`./worker${extension}`, currentModuleUrl);
|
||||
}
|
||||
62
packages/code-mode-runtime/src/types.ts
Normal file
62
packages/code-mode-runtime/src/types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type CodeModeBridgeMethod = "search" | "describe" | "call" | "yield";
|
||||
|
||||
export type CodeModeRuntimeConfig = {
|
||||
timeoutMs: number;
|
||||
memoryLimitBytes: number;
|
||||
maxPendingToolCalls: number;
|
||||
maxSnapshotBytes: number;
|
||||
};
|
||||
|
||||
export type CodeModePendingBridgeRequest = {
|
||||
id: string;
|
||||
method: CodeModeBridgeMethod;
|
||||
args: unknown[];
|
||||
};
|
||||
|
||||
export type CodeModeSettledBridgeRequest = {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type CodeModeWorkerInput =
|
||||
| {
|
||||
kind: "exec";
|
||||
source: string;
|
||||
config: CodeModeRuntimeConfig;
|
||||
catalog: unknown[];
|
||||
}
|
||||
| {
|
||||
kind: "resume";
|
||||
snapshotBytes: Uint8Array;
|
||||
config: CodeModeRuntimeConfig;
|
||||
settledRequests: CodeModeSettledBridgeRequest[];
|
||||
};
|
||||
|
||||
export type CodeModeFailureCode =
|
||||
| "invalid_input"
|
||||
| "runtime_unavailable"
|
||||
| "timeout"
|
||||
| "output_limit_exceeded"
|
||||
| "snapshot_limit_exceeded"
|
||||
| "internal_error";
|
||||
|
||||
export type CodeModeWorkerResult =
|
||||
| {
|
||||
status: "completed";
|
||||
value: unknown;
|
||||
output: unknown[];
|
||||
}
|
||||
| {
|
||||
status: "waiting";
|
||||
snapshotBytes: Uint8Array;
|
||||
pendingRequests: CodeModePendingBridgeRequest[];
|
||||
output: unknown[];
|
||||
}
|
||||
| {
|
||||
status: "failed";
|
||||
error: string;
|
||||
code: CodeModeFailureCode;
|
||||
output: unknown[];
|
||||
};
|
||||
555
packages/code-mode-runtime/src/worker.ts
Normal file
555
packages/code-mode-runtime/src/worker.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import { EvalFlags, Intrinsics, JSException, QuickJS, type JSValueHandle } from "quickjs-wasi";
|
||||
import type {
|
||||
CodeModePendingBridgeRequest,
|
||||
CodeModeRuntimeConfig,
|
||||
CodeModeSettledBridgeRequest,
|
||||
CodeModeWorkerInput,
|
||||
CodeModeWorkerResult,
|
||||
} from "./types.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const QUICKJS_WASM_PATH = require.resolve("quickjs-wasi/quickjs.wasm");
|
||||
let quickJsWasmModulePromise: Promise<WebAssembly.Module> | undefined;
|
||||
|
||||
class CodeModeWorkerFailure extends Error {
|
||||
readonly code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"];
|
||||
|
||||
constructor(
|
||||
code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"],
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "CodeModeWorkerFailure";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
class CodeModeWorkerFailureWithOutput extends CodeModeWorkerFailure {
|
||||
readonly output: unknown[];
|
||||
|
||||
constructor(
|
||||
code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"],
|
||||
message: string,
|
||||
output: unknown[],
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "CodeModeWorkerFailureWithOutput";
|
||||
this.output = output;
|
||||
}
|
||||
}
|
||||
|
||||
class CodeModeGuestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CodeModeGuestError";
|
||||
}
|
||||
}
|
||||
|
||||
function isQuickJsInterruptedError(error: unknown): boolean {
|
||||
if (error instanceof CodeModeGuestError) {
|
||||
return false;
|
||||
}
|
||||
return errorMessage(error) === "interrupted";
|
||||
}
|
||||
|
||||
type VmRun = {
|
||||
vm: QuickJS;
|
||||
didTimeout: () => boolean;
|
||||
};
|
||||
|
||||
function getQuickJsWasmModule(): Promise<WebAssembly.Module> {
|
||||
quickJsWasmModulePromise ??= readFile(QUICKJS_WASM_PATH).then((bytes) =>
|
||||
WebAssembly.compile(bytes),
|
||||
);
|
||||
return quickJsWasmModulePromise;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function toJsonSafe(value: unknown): unknown {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as unknown;
|
||||
} catch {
|
||||
if (value instanceof Error) {
|
||||
return { name: value.name, message: value.message };
|
||||
}
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
return value;
|
||||
case "bigint":
|
||||
case "symbol":
|
||||
case "function":
|
||||
return String(value);
|
||||
default:
|
||||
return Object.prototype.toString.call(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof JSException) {
|
||||
return error.stack || error.message || String(error);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message || String(error);
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
const CONTROLLER_SOURCE = String.raw`
|
||||
(() => {
|
||||
const output = [];
|
||||
const pending = new Map();
|
||||
const catalog = Array.isArray(globalThis.__openclawCatalog) ? globalThis.__openclawCatalog : [];
|
||||
|
||||
function safe(value) {
|
||||
if (value === undefined) return null;
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
if (value instanceof Error) {
|
||||
return { name: value.name, message: value.message };
|
||||
}
|
||||
if (value === null) return null;
|
||||
const type = typeof value;
|
||||
if (type === "string" || type === "number" || type === "boolean") return value;
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function asText(value) {
|
||||
if (typeof value === "string") return value;
|
||||
const encoded = JSON.stringify(safe(value));
|
||||
return typeof encoded === "string" ? encoded : String(value);
|
||||
}
|
||||
|
||||
function request(method, args) {
|
||||
const id = String(globalThis.__openclawHostRequest(String(method), JSON.stringify(safe(args ?? []))));
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
function settle(id, ok, payload) {
|
||||
const entry = pending.get(String(id));
|
||||
if (!entry) return false;
|
||||
pending.delete(String(id));
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(String(payload));
|
||||
} catch {
|
||||
parsed = String(payload);
|
||||
}
|
||||
if (ok) {
|
||||
entry.resolve(parsed);
|
||||
} else {
|
||||
const error = new Error(typeof parsed === "string" ? parsed : parsed?.message ?? "nested tool failed");
|
||||
entry.reject(error);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const baseTools = Object.create(null);
|
||||
Object.defineProperties(baseTools, {
|
||||
search: { value: (query, options) => request("search", [query, options]), enumerable: true },
|
||||
describe: { value: (id) => request("describe", [id]), enumerable: true },
|
||||
call: { value: (id, input) => request("call", [id, input]), enumerable: true },
|
||||
});
|
||||
|
||||
const safeNameCounts = new Map();
|
||||
for (const tool of catalog) {
|
||||
const name = typeof tool?.name === "string" ? tool.name : "";
|
||||
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) continue;
|
||||
safeNameCounts.set(name, (safeNameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
for (const tool of catalog) {
|
||||
const name = typeof tool?.name === "string" ? tool.name : "";
|
||||
const id = typeof tool?.id === "string" ? tool.id : "";
|
||||
if (!id || safeNameCounts.get(name) !== 1 || Object.prototype.hasOwnProperty.call(baseTools, name)) {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(baseTools, name, {
|
||||
value: (input) => request("call", [id, input]),
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperties(globalThis, {
|
||||
ALL_TOOLS: { value: Object.freeze(catalog.slice()), enumerable: true },
|
||||
tools: { value: Object.freeze(baseTools), enumerable: true },
|
||||
text: { value: (value) => output.push({ type: "text", text: asText(value) }), enumerable: true },
|
||||
json: { value: (value) => output.push({ type: "json", value: safe(value) }), enumerable: true },
|
||||
yield_control: { value: (reason) => request("yield", [reason]), enumerable: true },
|
||||
__openclawSettleBridge: { value: settle },
|
||||
__openclawTakeOutput: { value: () => output.splice(0) },
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
function buildUserSource(code: string): string {
|
||||
return `globalThis.__openclawResult = (async () => {\n${code}\n})()`;
|
||||
}
|
||||
|
||||
function createHostRequestHandler(params: {
|
||||
vm: QuickJS;
|
||||
pendingRequests: CodeModePendingBridgeRequest[];
|
||||
config: CodeModeRuntimeConfig;
|
||||
}): (this: JSValueHandle, method: JSValueHandle, argsJson: JSValueHandle) => JSValueHandle {
|
||||
return (methodHandle, argsHandle) => {
|
||||
if (params.pendingRequests.length >= params.config.maxPendingToolCalls) {
|
||||
throw new Error("too many pending code mode tool calls");
|
||||
}
|
||||
const method = methodHandle.toString();
|
||||
if (method !== "search" && method !== "describe" && method !== "call" && method !== "yield") {
|
||||
throw new Error("unsupported code mode bridge method");
|
||||
}
|
||||
let args: unknown = [];
|
||||
try {
|
||||
args = JSON.parse(argsHandle.toString()) as unknown;
|
||||
} catch {
|
||||
args = [];
|
||||
}
|
||||
const id = `bridge:${params.pendingRequests.length + 1}:${randomUUID()}`;
|
||||
params.pendingRequests.push({
|
||||
id,
|
||||
method,
|
||||
args: Array.isArray(args) ? args : [],
|
||||
});
|
||||
return params.vm.newString(id);
|
||||
};
|
||||
}
|
||||
|
||||
async function createVm(params: {
|
||||
catalog: unknown[];
|
||||
config: CodeModeRuntimeConfig;
|
||||
pendingRequests: CodeModePendingBridgeRequest[];
|
||||
}): Promise<VmRun> {
|
||||
const startedAt = Date.now();
|
||||
let timedOut = false;
|
||||
const vm = await QuickJS.create({
|
||||
wasm: await getQuickJsWasmModule(),
|
||||
memoryLimit: params.config.memoryLimitBytes,
|
||||
intrinsics: Intrinsics.ALL,
|
||||
timezoneOffset: 0,
|
||||
interruptHandler: () => {
|
||||
timedOut = Date.now() - startedAt > params.config.timeoutMs;
|
||||
return timedOut;
|
||||
},
|
||||
});
|
||||
const catalogHandle = vm.hostToHandle(params.catalog);
|
||||
try {
|
||||
vm.setProp(vm.global, "__openclawCatalog", catalogHandle);
|
||||
} finally {
|
||||
catalogHandle.dispose();
|
||||
}
|
||||
const hostRequest = vm.newFunction(
|
||||
"__openclawHostRequest",
|
||||
createHostRequestHandler({
|
||||
vm,
|
||||
pendingRequests: params.pendingRequests,
|
||||
config: params.config,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
vm.setProp(vm.global, "__openclawHostRequest", hostRequest);
|
||||
} finally {
|
||||
hostRequest.dispose();
|
||||
}
|
||||
vm.evalCode(CONTROLLER_SOURCE, "openclaw-code-mode:controller.js").dispose();
|
||||
return { vm, didTimeout: () => timedOut };
|
||||
}
|
||||
|
||||
async function restoreVm(params: {
|
||||
snapshotBytes: Uint8Array;
|
||||
config: CodeModeRuntimeConfig;
|
||||
pendingRequests: CodeModePendingBridgeRequest[];
|
||||
}): Promise<VmRun> {
|
||||
const startedAt = Date.now();
|
||||
let timedOut = false;
|
||||
const snapshot = QuickJS.deserializeSnapshot(params.snapshotBytes);
|
||||
const vm = await QuickJS.restore(snapshot, {
|
||||
wasm: await getQuickJsWasmModule(),
|
||||
memoryLimit: params.config.memoryLimitBytes,
|
||||
intrinsics: Intrinsics.ALL,
|
||||
timezoneOffset: 0,
|
||||
interruptHandler: () => {
|
||||
timedOut = Date.now() - startedAt > params.config.timeoutMs;
|
||||
return timedOut;
|
||||
},
|
||||
});
|
||||
vm.registerHostCallback(
|
||||
"__openclawHostRequest",
|
||||
createHostRequestHandler({
|
||||
vm,
|
||||
pendingRequests: params.pendingRequests,
|
||||
config: params.config,
|
||||
}),
|
||||
);
|
||||
return { vm, didTimeout: () => timedOut };
|
||||
}
|
||||
|
||||
function takeOutput(vm: QuickJS): unknown[] {
|
||||
const take = vm.global.getProp("__openclawTakeOutput");
|
||||
try {
|
||||
const output = vm.callFunction(take, vm.undefined);
|
||||
try {
|
||||
const dumped = vm.dump(output);
|
||||
return Array.isArray(dumped) ? (dumped as unknown[]) : [];
|
||||
} finally {
|
||||
output.dispose();
|
||||
}
|
||||
} finally {
|
||||
take.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function takeOutputSafely(vm: QuickJS): unknown[] {
|
||||
try {
|
||||
return takeOutput(vm);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function throwWorkerFailureWithOutput(params: {
|
||||
error: unknown;
|
||||
didTimeout: () => boolean;
|
||||
output: unknown[];
|
||||
vm: QuickJS;
|
||||
}): never {
|
||||
const timedOut = params.didTimeout() || isQuickJsInterruptedError(params.error);
|
||||
const failureOutput = params.output.length > 0 ? params.output : takeOutputSafely(params.vm);
|
||||
if (timedOut) {
|
||||
throw new CodeModeWorkerFailureWithOutput(
|
||||
"timeout",
|
||||
"code mode timeout exceeded",
|
||||
failureOutput,
|
||||
{ cause: params.error },
|
||||
);
|
||||
}
|
||||
if (params.error instanceof CodeModeWorkerFailure) {
|
||||
throw new CodeModeWorkerFailureWithOutput(
|
||||
params.error.code,
|
||||
params.error.message,
|
||||
failureOutput,
|
||||
{ cause: params.error },
|
||||
);
|
||||
}
|
||||
if (failureOutput.length > 0) {
|
||||
throw new CodeModeWorkerFailureWithOutput(
|
||||
"internal_error",
|
||||
errorMessage(params.error),
|
||||
failureOutput,
|
||||
{ cause: params.error },
|
||||
);
|
||||
}
|
||||
throw params.error;
|
||||
}
|
||||
|
||||
function drainPendingJobs(vm: QuickJS): void {
|
||||
for (let index = 0; index < 1000; index += 1) {
|
||||
if (vm.executePendingJobs() === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error("code mode pending job limit exceeded");
|
||||
}
|
||||
|
||||
function getResultHandle(vm: QuickJS): JSValueHandle {
|
||||
return vm.global.getProp("__openclawResult");
|
||||
}
|
||||
|
||||
async function readCompletedResult(vm: QuickJS, resultHandle: JSValueHandle): Promise<unknown> {
|
||||
if (!resultHandle.isPromise) {
|
||||
return toJsonSafe(vm.dump(resultHandle));
|
||||
}
|
||||
const settled = await vm.resolvePromise(resultHandle);
|
||||
if ("error" in settled) {
|
||||
try {
|
||||
throw new CodeModeGuestError(errorMessage(vm.dump(settled.error)));
|
||||
} finally {
|
||||
settled.error.dispose();
|
||||
}
|
||||
}
|
||||
try {
|
||||
return toJsonSafe(vm.dump(settled.value));
|
||||
} finally {
|
||||
settled.value.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function waitingResult(params: {
|
||||
vm: QuickJS;
|
||||
pendingRequests: CodeModePendingBridgeRequest[];
|
||||
output: unknown[];
|
||||
config: CodeModeRuntimeConfig;
|
||||
}): CodeModeWorkerResult {
|
||||
const snapshotBytes = QuickJS.serializeSnapshot(params.vm.snapshot());
|
||||
if (snapshotBytes.byteLength > params.config.maxSnapshotBytes) {
|
||||
throw new CodeModeWorkerFailure("snapshot_limit_exceeded", "code mode snapshot limit exceeded");
|
||||
}
|
||||
return {
|
||||
status: "waiting",
|
||||
snapshotBytes,
|
||||
pendingRequests: params.pendingRequests,
|
||||
output: params.output,
|
||||
};
|
||||
}
|
||||
|
||||
async function runExec(input: Extract<CodeModeWorkerInput, { kind: "exec" }>) {
|
||||
const pendingRequests: CodeModePendingBridgeRequest[] = [];
|
||||
const { vm, didTimeout } = await createVm({
|
||||
catalog: input.catalog,
|
||||
config: input.config,
|
||||
pendingRequests,
|
||||
});
|
||||
let output: unknown[] = [];
|
||||
try {
|
||||
vm.evalCode(
|
||||
buildUserSource(input.source),
|
||||
"openclaw-code-mode:user.js",
|
||||
EvalFlags.ASYNC,
|
||||
).dispose();
|
||||
drainPendingJobs(vm);
|
||||
output = takeOutput(vm);
|
||||
const resultHandle = getResultHandle(vm);
|
||||
try {
|
||||
if (pendingRequests.length > 0) {
|
||||
return waitingResult({ vm, pendingRequests, output, config: input.config });
|
||||
}
|
||||
if (resultHandle.isPromise && resultHandle.promiseState === 0) {
|
||||
throw new Error("code mode promise is pending without host work");
|
||||
}
|
||||
return {
|
||||
status: "completed" as const,
|
||||
value: await readCompletedResult(vm, resultHandle),
|
||||
output,
|
||||
};
|
||||
} finally {
|
||||
resultHandle.dispose();
|
||||
}
|
||||
} catch (error) {
|
||||
return throwWorkerFailureWithOutput({ error, didTimeout, output, vm });
|
||||
} finally {
|
||||
vm.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function runResume(input: Extract<CodeModeWorkerInput, { kind: "resume" }>) {
|
||||
const pendingRequests: CodeModePendingBridgeRequest[] = [];
|
||||
const { vm, didTimeout } = await restoreVm({
|
||||
snapshotBytes: input.snapshotBytes,
|
||||
config: input.config,
|
||||
pendingRequests,
|
||||
});
|
||||
let output: unknown[] = [];
|
||||
try {
|
||||
const settle = vm.global.getProp("__openclawSettleBridge");
|
||||
try {
|
||||
for (const request of input.settledRequests) {
|
||||
const id = vm.newString(request.id);
|
||||
const payload = vm.newString(JSON.stringify(request.ok ? request.value : request.error));
|
||||
try {
|
||||
vm.callFunction(
|
||||
settle,
|
||||
vm.undefined,
|
||||
id,
|
||||
request.ok ? vm.true : vm.false,
|
||||
payload,
|
||||
).dispose();
|
||||
} finally {
|
||||
id.dispose();
|
||||
payload.dispose();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
settle.dispose();
|
||||
}
|
||||
drainPendingJobs(vm);
|
||||
output = takeOutput(vm);
|
||||
const resultHandle = getResultHandle(vm);
|
||||
try {
|
||||
if (pendingRequests.length > 0) {
|
||||
return waitingResult({ vm, pendingRequests, output, config: input.config });
|
||||
}
|
||||
if (resultHandle.isPromise && resultHandle.promiseState === 0) {
|
||||
throw new Error("code mode promise is pending without host work");
|
||||
}
|
||||
return {
|
||||
status: "completed" as const,
|
||||
value: await readCompletedResult(vm, resultHandle),
|
||||
output,
|
||||
};
|
||||
} finally {
|
||||
resultHandle.dispose();
|
||||
}
|
||||
} catch (error) {
|
||||
return throwWorkerFailureWithOutput({ error, didTimeout, output, vm });
|
||||
} finally {
|
||||
vm.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<CodeModeWorkerResult> {
|
||||
const input = workerData as unknown;
|
||||
if (!isRecord(input) || !isRecord(input.config)) {
|
||||
return {
|
||||
status: "failed",
|
||||
error: "invalid code mode worker input",
|
||||
code: "invalid_input",
|
||||
output: [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
if (input.kind === "exec" && typeof input.source === "string") {
|
||||
return await runExec({
|
||||
kind: "exec",
|
||||
source: input.source,
|
||||
config: input.config as CodeModeRuntimeConfig,
|
||||
catalog: Array.isArray(input.catalog) ? input.catalog : [],
|
||||
});
|
||||
}
|
||||
if (input.kind === "resume" && input.snapshotBytes instanceof Uint8Array) {
|
||||
return await runResume({
|
||||
kind: "resume",
|
||||
snapshotBytes: input.snapshotBytes,
|
||||
config: input.config as CodeModeRuntimeConfig,
|
||||
settledRequests: Array.isArray(input.settledRequests)
|
||||
? (input.settledRequests as CodeModeSettledBridgeRequest[])
|
||||
: [],
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: "failed",
|
||||
error: "invalid code mode worker input",
|
||||
code: "invalid_input",
|
||||
output: [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failed",
|
||||
error: errorMessage(error),
|
||||
code: error instanceof CodeModeWorkerFailure ? error.code : "internal_error",
|
||||
output: error instanceof CodeModeWorkerFailureWithOutput ? error.output : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line unicorn/require-post-message-target-origin -- Node worker_threads MessagePort, not window.postMessage.
|
||||
parentPort?.postMessage(await main());
|
||||
8
packages/code-mode-runtime/tsconfig.json
Normal file
8
packages/code-mode-runtime/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -1788,6 +1788,12 @@ importers:
|
||||
specifier: 2.9.0
|
||||
version: 2.9.0
|
||||
|
||||
packages/code-mode-runtime:
|
||||
dependencies:
|
||||
quickjs-wasi:
|
||||
specifier: 3.0.0
|
||||
version: 3.0.0
|
||||
|
||||
packages/gateway-client:
|
||||
dependencies:
|
||||
'@openclaw/gateway-protocol':
|
||||
|
||||
@@ -8,6 +8,7 @@ const RUN_NODE_PACKAGE_SOURCE_ROOTS = [
|
||||
// Root runtime code imports these package sources through tsconfig aliases,
|
||||
// while pnpm dev/watch still runs the root dist entrypoint. Treat them like
|
||||
// src/ so edits restart the same process that consumes them.
|
||||
"packages/code-mode-runtime/src",
|
||||
"packages/gateway-client/src",
|
||||
"packages/gateway-protocol/src",
|
||||
"packages/markdown-core/src",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveCodeModeRuntimeWorkerUrl } from "@openclaw/code-mode-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setPluginToolMeta } from "../plugins/tools.js";
|
||||
import {
|
||||
@@ -182,6 +183,13 @@ describe("Code Mode", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the package worker URL in the runtime package dist", () => {
|
||||
expect(
|
||||
resolveCodeModeRuntimeWorkerUrl("file:///repo/dist/packages/code-mode-runtime/index.js")
|
||||
.pathname,
|
||||
).toBe("/repo/dist/packages/code-mode-runtime/worker.js");
|
||||
});
|
||||
|
||||
it("hides all normal tools behind exec and wait", () => {
|
||||
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
|
||||
const shellExec = fakeTool("exec", "Run shell command");
|
||||
|
||||
@@ -2,6 +2,12 @@ import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type {
|
||||
CodeModeFailureCode,
|
||||
CodeModePendingBridgeRequest as PendingBridgeRequest,
|
||||
CodeModeSettledBridgeRequest as SettledBridgeRequest,
|
||||
CodeModeWorkerResult,
|
||||
} from "@openclaw/code-mode-runtime";
|
||||
import { Type } from "typebox";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isRecord } from "../shared/record-coerce.js";
|
||||
@@ -68,21 +74,6 @@ export type CodeModeConfig = {
|
||||
maxSearchLimit: number;
|
||||
};
|
||||
|
||||
type CodeModeBridgeMethod = "search" | "describe" | "call" | "yield";
|
||||
|
||||
type PendingBridgeRequest = {
|
||||
id: string;
|
||||
method: CodeModeBridgeMethod;
|
||||
args: unknown[];
|
||||
};
|
||||
|
||||
type SettledBridgeRequest = {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type PendingBridgeState = PendingBridgeRequest & {
|
||||
promise: Promise<SettledBridgeRequest>;
|
||||
settled?: SettledBridgeRequest;
|
||||
@@ -103,33 +94,6 @@ type CodeModeRunState = {
|
||||
|
||||
type CodeModeToolContext = ToolSearchToolContext;
|
||||
|
||||
type CodeModeFailureCode =
|
||||
| "invalid_input"
|
||||
| "runtime_unavailable"
|
||||
| "timeout"
|
||||
| "output_limit_exceeded"
|
||||
| "snapshot_limit_exceeded"
|
||||
| "internal_error";
|
||||
|
||||
type CodeModeWorkerResult =
|
||||
| {
|
||||
status: "completed";
|
||||
value: unknown;
|
||||
output: unknown[];
|
||||
}
|
||||
| {
|
||||
status: "waiting";
|
||||
snapshotBytes: Uint8Array;
|
||||
pendingRequests: PendingBridgeRequest[];
|
||||
output: unknown[];
|
||||
}
|
||||
| {
|
||||
status: "failed";
|
||||
error: string;
|
||||
code: CodeModeFailureCode;
|
||||
output: unknown[];
|
||||
};
|
||||
|
||||
const activeRuns = new Map<string, CodeModeRunState>();
|
||||
const resumingRunIds = new Set<string>();
|
||||
let typescriptRuntimePromise: Promise<typeof import("typescript")> | null = null;
|
||||
|
||||
@@ -1,608 +1 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import { EvalFlags, Intrinsics, JSException, QuickJS, type JSValueHandle } from "quickjs-wasi";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const QUICKJS_WASM_PATH = require.resolve("quickjs-wasi/quickjs.wasm");
|
||||
let quickJsWasmModulePromise: Promise<WebAssembly.Module> | undefined;
|
||||
|
||||
type CodeModeBridgeMethod = "search" | "describe" | "call" | "yield";
|
||||
|
||||
type CodeModeConfig = {
|
||||
timeoutMs: number;
|
||||
memoryLimitBytes: number;
|
||||
maxPendingToolCalls: number;
|
||||
maxSnapshotBytes: number;
|
||||
};
|
||||
|
||||
type PendingBridgeRequest = {
|
||||
id: string;
|
||||
method: CodeModeBridgeMethod;
|
||||
args: unknown[];
|
||||
};
|
||||
|
||||
type SettledBridgeRequest = {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type CodeModeWorkerInput =
|
||||
| {
|
||||
kind: "exec";
|
||||
source: string;
|
||||
config: CodeModeConfig;
|
||||
catalog: unknown[];
|
||||
}
|
||||
| {
|
||||
kind: "resume";
|
||||
snapshotBytes: Uint8Array;
|
||||
config: CodeModeConfig;
|
||||
settledRequests: SettledBridgeRequest[];
|
||||
};
|
||||
|
||||
type CodeModeWorkerResult =
|
||||
| {
|
||||
status: "completed";
|
||||
value: unknown;
|
||||
output: unknown[];
|
||||
}
|
||||
| {
|
||||
status: "waiting";
|
||||
snapshotBytes: Uint8Array;
|
||||
pendingRequests: PendingBridgeRequest[];
|
||||
output: unknown[];
|
||||
}
|
||||
| {
|
||||
status: "failed";
|
||||
error: string;
|
||||
code:
|
||||
| "invalid_input"
|
||||
| "runtime_unavailable"
|
||||
| "timeout"
|
||||
| "snapshot_limit_exceeded"
|
||||
| "internal_error";
|
||||
output: unknown[];
|
||||
};
|
||||
|
||||
class CodeModeWorkerFailure extends Error {
|
||||
readonly code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"];
|
||||
|
||||
constructor(
|
||||
code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"],
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "CodeModeWorkerFailure";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
class CodeModeWorkerFailureWithOutput extends CodeModeWorkerFailure {
|
||||
readonly output: unknown[];
|
||||
|
||||
constructor(
|
||||
code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"],
|
||||
message: string,
|
||||
output: unknown[],
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "CodeModeWorkerFailureWithOutput";
|
||||
this.output = output;
|
||||
}
|
||||
}
|
||||
|
||||
class CodeModeGuestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CodeModeGuestError";
|
||||
}
|
||||
}
|
||||
|
||||
function isQuickJsInterruptedError(error: unknown): boolean {
|
||||
if (error instanceof CodeModeGuestError) {
|
||||
return false;
|
||||
}
|
||||
return errorMessage(error) === "interrupted";
|
||||
}
|
||||
|
||||
type VmRun = {
|
||||
vm: QuickJS;
|
||||
didTimeout: () => boolean;
|
||||
};
|
||||
|
||||
function getQuickJsWasmModule(): Promise<WebAssembly.Module> {
|
||||
quickJsWasmModulePromise ??= readFile(QUICKJS_WASM_PATH).then((bytes) =>
|
||||
WebAssembly.compile(bytes),
|
||||
);
|
||||
return quickJsWasmModulePromise;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function toJsonSafe(value: unknown): unknown {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as unknown;
|
||||
} catch {
|
||||
if (value instanceof Error) {
|
||||
return { name: value.name, message: value.message };
|
||||
}
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
return value;
|
||||
case "bigint":
|
||||
case "symbol":
|
||||
case "function":
|
||||
return String(value);
|
||||
default:
|
||||
return Object.prototype.toString.call(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof JSException) {
|
||||
return error.stack || error.message || String(error);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message || String(error);
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
const CONTROLLER_SOURCE = String.raw`
|
||||
(() => {
|
||||
const output = [];
|
||||
const pending = new Map();
|
||||
const catalog = Array.isArray(globalThis.__openclawCatalog) ? globalThis.__openclawCatalog : [];
|
||||
|
||||
function safe(value) {
|
||||
if (value === undefined) return null;
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
if (value instanceof Error) {
|
||||
return { name: value.name, message: value.message };
|
||||
}
|
||||
if (value === null) return null;
|
||||
const type = typeof value;
|
||||
if (type === "string" || type === "number" || type === "boolean") return value;
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function asText(value) {
|
||||
if (typeof value === "string") return value;
|
||||
const encoded = JSON.stringify(safe(value));
|
||||
return typeof encoded === "string" ? encoded : String(value);
|
||||
}
|
||||
|
||||
function request(method, args) {
|
||||
const id = String(globalThis.__openclawHostRequest(String(method), JSON.stringify(safe(args ?? []))));
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
function settle(id, ok, payload) {
|
||||
const entry = pending.get(String(id));
|
||||
if (!entry) return false;
|
||||
pending.delete(String(id));
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(String(payload));
|
||||
} catch {
|
||||
parsed = String(payload);
|
||||
}
|
||||
if (ok) {
|
||||
entry.resolve(parsed);
|
||||
} else {
|
||||
const error = new Error(typeof parsed === "string" ? parsed : parsed?.message ?? "nested tool failed");
|
||||
entry.reject(error);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const baseTools = Object.create(null);
|
||||
Object.defineProperties(baseTools, {
|
||||
search: { value: (query, options) => request("search", [query, options]), enumerable: true },
|
||||
describe: { value: (id) => request("describe", [id]), enumerable: true },
|
||||
call: { value: (id, input) => request("call", [id, input]), enumerable: true },
|
||||
});
|
||||
|
||||
const safeNameCounts = new Map();
|
||||
for (const tool of catalog) {
|
||||
const name = typeof tool?.name === "string" ? tool.name : "";
|
||||
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) continue;
|
||||
safeNameCounts.set(name, (safeNameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
for (const tool of catalog) {
|
||||
const name = typeof tool?.name === "string" ? tool.name : "";
|
||||
const id = typeof tool?.id === "string" ? tool.id : "";
|
||||
if (!id || safeNameCounts.get(name) !== 1 || Object.prototype.hasOwnProperty.call(baseTools, name)) {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(baseTools, name, {
|
||||
value: (input) => request("call", [id, input]),
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperties(globalThis, {
|
||||
ALL_TOOLS: { value: Object.freeze(catalog.slice()), enumerable: true },
|
||||
tools: { value: Object.freeze(baseTools), enumerable: true },
|
||||
text: { value: (value) => output.push({ type: "text", text: asText(value) }), enumerable: true },
|
||||
json: { value: (value) => output.push({ type: "json", value: safe(value) }), enumerable: true },
|
||||
yield_control: { value: (reason) => request("yield", [reason]), enumerable: true },
|
||||
__openclawSettleBridge: { value: settle },
|
||||
__openclawTakeOutput: { value: () => output.splice(0) },
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
function buildUserSource(code: string): string {
|
||||
return `globalThis.__openclawResult = (async () => {\n${code}\n})()`;
|
||||
}
|
||||
|
||||
function createHostRequestHandler(params: {
|
||||
vm: QuickJS;
|
||||
pendingRequests: PendingBridgeRequest[];
|
||||
config: CodeModeConfig;
|
||||
}): (this: JSValueHandle, method: JSValueHandle, argsJson: JSValueHandle) => JSValueHandle {
|
||||
return (methodHandle, argsHandle) => {
|
||||
if (params.pendingRequests.length >= params.config.maxPendingToolCalls) {
|
||||
throw new Error("too many pending code mode tool calls");
|
||||
}
|
||||
const method = methodHandle.toString();
|
||||
if (method !== "search" && method !== "describe" && method !== "call" && method !== "yield") {
|
||||
throw new Error("unsupported code mode bridge method");
|
||||
}
|
||||
let args: unknown = [];
|
||||
try {
|
||||
args = JSON.parse(argsHandle.toString()) as unknown;
|
||||
} catch {
|
||||
args = [];
|
||||
}
|
||||
const id = `bridge:${params.pendingRequests.length + 1}:${randomUUID()}`;
|
||||
params.pendingRequests.push({
|
||||
id,
|
||||
method,
|
||||
args: Array.isArray(args) ? args : [],
|
||||
});
|
||||
return params.vm.newString(id);
|
||||
};
|
||||
}
|
||||
|
||||
async function createVm(params: {
|
||||
catalog: unknown[];
|
||||
config: CodeModeConfig;
|
||||
pendingRequests: PendingBridgeRequest[];
|
||||
}): Promise<VmRun> {
|
||||
const startedAt = Date.now();
|
||||
let timedOut = false;
|
||||
const vm = await QuickJS.create({
|
||||
wasm: await getQuickJsWasmModule(),
|
||||
memoryLimit: params.config.memoryLimitBytes,
|
||||
intrinsics: Intrinsics.ALL,
|
||||
timezoneOffset: 0,
|
||||
interruptHandler: () => {
|
||||
timedOut = Date.now() - startedAt > params.config.timeoutMs;
|
||||
return timedOut;
|
||||
},
|
||||
});
|
||||
const catalogHandle = vm.hostToHandle(params.catalog);
|
||||
try {
|
||||
vm.setProp(vm.global, "__openclawCatalog", catalogHandle);
|
||||
} finally {
|
||||
catalogHandle.dispose();
|
||||
}
|
||||
const hostRequest = vm.newFunction(
|
||||
"__openclawHostRequest",
|
||||
createHostRequestHandler({
|
||||
vm,
|
||||
pendingRequests: params.pendingRequests,
|
||||
config: params.config,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
vm.setProp(vm.global, "__openclawHostRequest", hostRequest);
|
||||
} finally {
|
||||
hostRequest.dispose();
|
||||
}
|
||||
vm.evalCode(CONTROLLER_SOURCE, "openclaw-code-mode:controller.js").dispose();
|
||||
return { vm, didTimeout: () => timedOut };
|
||||
}
|
||||
|
||||
async function restoreVm(params: {
|
||||
snapshotBytes: Uint8Array;
|
||||
config: CodeModeConfig;
|
||||
pendingRequests: PendingBridgeRequest[];
|
||||
}): Promise<VmRun> {
|
||||
const startedAt = Date.now();
|
||||
let timedOut = false;
|
||||
const snapshot = QuickJS.deserializeSnapshot(params.snapshotBytes);
|
||||
const vm = await QuickJS.restore(snapshot, {
|
||||
wasm: await getQuickJsWasmModule(),
|
||||
memoryLimit: params.config.memoryLimitBytes,
|
||||
intrinsics: Intrinsics.ALL,
|
||||
timezoneOffset: 0,
|
||||
interruptHandler: () => {
|
||||
timedOut = Date.now() - startedAt > params.config.timeoutMs;
|
||||
return timedOut;
|
||||
},
|
||||
});
|
||||
vm.registerHostCallback(
|
||||
"__openclawHostRequest",
|
||||
createHostRequestHandler({
|
||||
vm,
|
||||
pendingRequests: params.pendingRequests,
|
||||
config: params.config,
|
||||
}),
|
||||
);
|
||||
return { vm, didTimeout: () => timedOut };
|
||||
}
|
||||
|
||||
function takeOutput(vm: QuickJS): unknown[] {
|
||||
const take = vm.global.getProp("__openclawTakeOutput");
|
||||
try {
|
||||
const output = vm.callFunction(take, vm.undefined);
|
||||
try {
|
||||
const dumped = vm.dump(output);
|
||||
return Array.isArray(dumped) ? (dumped as unknown[]) : [];
|
||||
} finally {
|
||||
output.dispose();
|
||||
}
|
||||
} finally {
|
||||
take.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function takeOutputSafely(vm: QuickJS): unknown[] {
|
||||
try {
|
||||
return takeOutput(vm);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function throwWorkerFailureWithOutput(params: {
|
||||
error: unknown;
|
||||
didTimeout: () => boolean;
|
||||
output: unknown[];
|
||||
vm: QuickJS;
|
||||
}): never {
|
||||
const timedOut = params.didTimeout() || isQuickJsInterruptedError(params.error);
|
||||
const failureOutput = params.output.length > 0 ? params.output : takeOutputSafely(params.vm);
|
||||
if (timedOut) {
|
||||
throw new CodeModeWorkerFailureWithOutput(
|
||||
"timeout",
|
||||
"code mode timeout exceeded",
|
||||
failureOutput,
|
||||
{ cause: params.error },
|
||||
);
|
||||
}
|
||||
if (params.error instanceof CodeModeWorkerFailure) {
|
||||
throw new CodeModeWorkerFailureWithOutput(
|
||||
params.error.code,
|
||||
params.error.message,
|
||||
failureOutput,
|
||||
{ cause: params.error },
|
||||
);
|
||||
}
|
||||
if (failureOutput.length > 0) {
|
||||
throw new CodeModeWorkerFailureWithOutput(
|
||||
"internal_error",
|
||||
errorMessage(params.error),
|
||||
failureOutput,
|
||||
{ cause: params.error },
|
||||
);
|
||||
}
|
||||
throw params.error;
|
||||
}
|
||||
|
||||
function drainPendingJobs(vm: QuickJS): void {
|
||||
for (let index = 0; index < 1000; index += 1) {
|
||||
if (vm.executePendingJobs() === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error("code mode pending job limit exceeded");
|
||||
}
|
||||
|
||||
function getResultHandle(vm: QuickJS): JSValueHandle {
|
||||
return vm.global.getProp("__openclawResult");
|
||||
}
|
||||
|
||||
async function readCompletedResult(vm: QuickJS, resultHandle: JSValueHandle): Promise<unknown> {
|
||||
if (!resultHandle.isPromise) {
|
||||
return toJsonSafe(vm.dump(resultHandle));
|
||||
}
|
||||
const settled = await vm.resolvePromise(resultHandle);
|
||||
if ("error" in settled) {
|
||||
try {
|
||||
throw new CodeModeGuestError(errorMessage(vm.dump(settled.error)));
|
||||
} finally {
|
||||
settled.error.dispose();
|
||||
}
|
||||
}
|
||||
try {
|
||||
return toJsonSafe(vm.dump(settled.value));
|
||||
} finally {
|
||||
settled.value.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function waitingResult(params: {
|
||||
vm: QuickJS;
|
||||
pendingRequests: PendingBridgeRequest[];
|
||||
output: unknown[];
|
||||
config: CodeModeConfig;
|
||||
}): CodeModeWorkerResult {
|
||||
const snapshotBytes = QuickJS.serializeSnapshot(params.vm.snapshot());
|
||||
if (snapshotBytes.byteLength > params.config.maxSnapshotBytes) {
|
||||
throw new CodeModeWorkerFailure("snapshot_limit_exceeded", "code mode snapshot limit exceeded");
|
||||
}
|
||||
return {
|
||||
status: "waiting",
|
||||
snapshotBytes,
|
||||
pendingRequests: params.pendingRequests,
|
||||
output: params.output,
|
||||
};
|
||||
}
|
||||
|
||||
async function runExec(input: Extract<CodeModeWorkerInput, { kind: "exec" }>) {
|
||||
const pendingRequests: PendingBridgeRequest[] = [];
|
||||
const { vm, didTimeout } = await createVm({
|
||||
catalog: input.catalog,
|
||||
config: input.config,
|
||||
pendingRequests,
|
||||
});
|
||||
let output: unknown[] = [];
|
||||
try {
|
||||
vm.evalCode(
|
||||
buildUserSource(input.source),
|
||||
"openclaw-code-mode:user.js",
|
||||
EvalFlags.ASYNC,
|
||||
).dispose();
|
||||
drainPendingJobs(vm);
|
||||
output = takeOutput(vm);
|
||||
const resultHandle = getResultHandle(vm);
|
||||
try {
|
||||
if (pendingRequests.length > 0) {
|
||||
return waitingResult({ vm, pendingRequests, output, config: input.config });
|
||||
}
|
||||
if (resultHandle.isPromise && resultHandle.promiseState === 0) {
|
||||
throw new Error("code mode promise is pending without host work");
|
||||
}
|
||||
return {
|
||||
status: "completed" as const,
|
||||
value: await readCompletedResult(vm, resultHandle),
|
||||
output,
|
||||
};
|
||||
} finally {
|
||||
resultHandle.dispose();
|
||||
}
|
||||
} catch (error) {
|
||||
return throwWorkerFailureWithOutput({ error, didTimeout, output, vm });
|
||||
} finally {
|
||||
vm.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function runResume(input: Extract<CodeModeWorkerInput, { kind: "resume" }>) {
|
||||
const pendingRequests: PendingBridgeRequest[] = [];
|
||||
const { vm, didTimeout } = await restoreVm({
|
||||
snapshotBytes: input.snapshotBytes,
|
||||
config: input.config,
|
||||
pendingRequests,
|
||||
});
|
||||
let output: unknown[] = [];
|
||||
try {
|
||||
const settle = vm.global.getProp("__openclawSettleBridge");
|
||||
try {
|
||||
for (const request of input.settledRequests) {
|
||||
const id = vm.newString(request.id);
|
||||
const payload = vm.newString(JSON.stringify(request.ok ? request.value : request.error));
|
||||
try {
|
||||
vm.callFunction(
|
||||
settle,
|
||||
vm.undefined,
|
||||
id,
|
||||
request.ok ? vm.true : vm.false,
|
||||
payload,
|
||||
).dispose();
|
||||
} finally {
|
||||
id.dispose();
|
||||
payload.dispose();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
settle.dispose();
|
||||
}
|
||||
drainPendingJobs(vm);
|
||||
output = takeOutput(vm);
|
||||
const resultHandle = getResultHandle(vm);
|
||||
try {
|
||||
if (pendingRequests.length > 0) {
|
||||
return waitingResult({ vm, pendingRequests, output, config: input.config });
|
||||
}
|
||||
if (resultHandle.isPromise && resultHandle.promiseState === 0) {
|
||||
throw new Error("code mode promise is pending without host work");
|
||||
}
|
||||
return {
|
||||
status: "completed" as const,
|
||||
value: await readCompletedResult(vm, resultHandle),
|
||||
output,
|
||||
};
|
||||
} finally {
|
||||
resultHandle.dispose();
|
||||
}
|
||||
} catch (error) {
|
||||
return throwWorkerFailureWithOutput({ error, didTimeout, output, vm });
|
||||
} finally {
|
||||
vm.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<CodeModeWorkerResult> {
|
||||
const input = workerData as unknown;
|
||||
if (!isRecord(input) || !isRecord(input.config)) {
|
||||
return {
|
||||
status: "failed",
|
||||
error: "invalid code mode worker input",
|
||||
code: "invalid_input",
|
||||
output: [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
if (input.kind === "exec" && typeof input.source === "string") {
|
||||
return await runExec({
|
||||
kind: "exec",
|
||||
source: input.source,
|
||||
config: input.config as CodeModeConfig,
|
||||
catalog: Array.isArray(input.catalog) ? input.catalog : [],
|
||||
});
|
||||
}
|
||||
if (input.kind === "resume" && input.snapshotBytes instanceof Uint8Array) {
|
||||
return await runResume({
|
||||
kind: "resume",
|
||||
snapshotBytes: input.snapshotBytes,
|
||||
config: input.config as CodeModeConfig,
|
||||
settledRequests: Array.isArray(input.settledRequests)
|
||||
? (input.settledRequests as SettledBridgeRequest[])
|
||||
: [],
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: "failed",
|
||||
error: "invalid code mode worker input",
|
||||
code: "invalid_input",
|
||||
output: [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failed",
|
||||
error: errorMessage(error),
|
||||
code: error instanceof CodeModeWorkerFailure ? error.code : "internal_error",
|
||||
output: error instanceof CodeModeWorkerFailureWithOutput ? error.output : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line unicorn/require-post-message-target-origin -- Node worker_threads MessagePort, not window.postMessage.
|
||||
parentPort?.postMessage(await main());
|
||||
import "../../packages/code-mode-runtime/src/worker.ts";
|
||||
|
||||
@@ -130,6 +130,7 @@ describe("watch-node script", () => {
|
||||
];
|
||||
expect(watchPaths).toEqual(runNodeWatchedPaths);
|
||||
expect(watchPaths).toContain("extensions");
|
||||
expect(watchPaths).toContain("packages/code-mode-runtime/src");
|
||||
expect(watchPaths).toContain("packages/gateway-client/src");
|
||||
expect(watchPaths).toContain("packages/gateway-protocol/src");
|
||||
expect(watchPaths).toContain("packages/markdown-core/src");
|
||||
@@ -139,6 +140,8 @@ describe("watch-node script", () => {
|
||||
expect(watchOptions.ignoreInitial).toBe(true);
|
||||
expect(watchOptions.ignored("src")).toBe(false);
|
||||
expect(watchOptions.ignored("src/infra")).toBe(false);
|
||||
expect(watchOptions.ignored("packages/code-mode-runtime/src/worker.ts")).toBe(false);
|
||||
expect(watchOptions.ignored("packages/code-mode-runtime/src/worker.test.ts")).toBe(true);
|
||||
expect(watchOptions.ignored("packages/gateway-client/src/client.ts")).toBe(false);
|
||||
expect(watchOptions.ignored("packages/gateway-client/src/client.test.ts")).toBe(true);
|
||||
expect(watchOptions.ignored("packages/gateway-protocol/src/schema/cron.ts")).toBe(false);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { listGitTrackedFiles, toRepoRelativePath } from "../../src/test-utils/re
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
||||
const IGNORED_DIRS = new Set([".cache", ".git", "build", "coverage", "dist", "node_modules"]);
|
||||
const ROOTS = ["src", "extensions", "scripts", "ui"] as const;
|
||||
const ROOTS = ["src", "extensions", "packages", "scripts", "ui"] as const;
|
||||
const SUPPRESSION_PATTERN = /(?:oxlint|eslint)-disable(?:-next-line)?\s+([@/\w-]+)(?:\s+--|$)/u;
|
||||
|
||||
type SuppressionEntry = {
|
||||
@@ -185,11 +185,11 @@ describe("production lint suppressions", () => {
|
||||
"extensions/telegram/src/telegram-ingress-worker.runtime.ts|unicorn/require-post-message-target-origin|1",
|
||||
"extensions/telegram/src/telegram-ingress-worker.ts|unicorn/require-post-message-target-origin|1",
|
||||
"extensions/whatsapp/src/document-filename.ts|no-control-regex|1",
|
||||
"packages/code-mode-runtime/src/worker.ts|unicorn/require-post-message-target-origin|1",
|
||||
"scripts/e2e/mcp-channels-harness.ts|unicorn/prefer-add-event-listener|1",
|
||||
"scripts/lib/extension-package-boundary.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"scripts/lib/plugin-npm-release.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/agents/agent-scope.ts|no-control-regex|1",
|
||||
"src/agents/code-mode.worker.ts|unicorn/require-post-message-target-origin|1",
|
||||
"src/agents/embedded-agent-runner/run/images.ts|no-control-regex|1",
|
||||
"src/agents/subagent-spawn.ts|no-control-regex|1",
|
||||
"src/channels/plugins/channel-runtime-surface.types.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
|
||||
@@ -173,6 +173,14 @@ export const sharedVitestConfig = {
|
||||
find: "@openclaw/whatsapp/api.js",
|
||||
replacement: path.join(repoRoot, "extensions", "whatsapp", "api.ts"),
|
||||
},
|
||||
{
|
||||
find: "@openclaw/code-mode-runtime/worker",
|
||||
replacement: path.join(repoRoot, "packages", "code-mode-runtime", "src", "worker.ts"),
|
||||
},
|
||||
{
|
||||
find: "@openclaw/code-mode-runtime",
|
||||
replacement: path.join(repoRoot, "packages", "code-mode-runtime", "src", "index.ts"),
|
||||
},
|
||||
{
|
||||
find: "@openclaw/gateway-client/readiness",
|
||||
replacement: path.join(repoRoot, "packages", "gateway-client", "src", "readiness.ts"),
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"openclaw/extension-api": ["./src/extensionAPI.ts"],
|
||||
"openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"],
|
||||
"openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"],
|
||||
"@openclaw/code-mode-runtime": ["./packages/code-mode-runtime/src/index.ts"],
|
||||
"@openclaw/code-mode-runtime/*": ["./packages/code-mode-runtime/src/*"],
|
||||
"@openclaw/agent-core": ["./packages/agent-core/src/index.ts"],
|
||||
"@openclaw/agent-core/*": ["./packages/agent-core/src/*"],
|
||||
"@openclaw/llm-core": ["./packages/llm-core/src/index.ts"],
|
||||
|
||||
@@ -372,6 +372,16 @@ function buildGatewayClientDistEntries(): Record<string, string> {
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodeModeRuntimeDistEntries(): Record<string, string> {
|
||||
return {
|
||||
// The root package still ships a stable agents/code-mode.worker.js entry.
|
||||
// These entries make the extracted runtime package buildable on its own.
|
||||
index: "packages/code-mode-runtime/src/index.ts",
|
||||
types: "packages/code-mode-runtime/src/types.ts",
|
||||
worker: "packages/code-mode-runtime/src/worker.ts",
|
||||
};
|
||||
}
|
||||
|
||||
function buildNetPolicyDistEntries(): Record<string, string> {
|
||||
return {
|
||||
// These subpaths are imported by root runtime code and exported by the
|
||||
@@ -593,6 +603,12 @@ export default defineConfig([
|
||||
neverBundle: shouldExternalizeGatewayClientDependency,
|
||||
},
|
||||
}),
|
||||
nodeWorkspacePackageBuildConfig({
|
||||
clean: true,
|
||||
dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined,
|
||||
entry: buildCodeModeRuntimeDistEntries(),
|
||||
outDir: "packages/code-mode-runtime/dist",
|
||||
}),
|
||||
nodeWorkspacePackageBuildConfig({
|
||||
clean: true,
|
||||
dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined,
|
||||
|
||||
Reference in New Issue
Block a user