feat(agent): migrate OpenCode 2 runtime and sandbox tools

Upgrade the runtime and native plugin API to OpenCode 2.0.24. Restore
session-scoped learning, credential renewal, typed project queries and
controlled frontend actions from the legacy Agent.

Replace host Shell execution with an approved Landlock/seccomp executor
and persistent per-session workspaces. Add typed Bocha search and Tianditu
geocoding bridges using the owning session credential. Require a working
kernel sandbox before startup and persist workspace files in deployments.

Validation completed before this commit: TypeScript checks, 150 Bun tests,
Docker build and UID 10001 sandbox execution, native Shell approval and
session recovery. Tianditu succeeded; Bocha currently reports exhausted
account quota. Unrelated investigation-report contract work is excluded.
This commit is contained in:
2026-10-08 15:46:37 +08:00
parent 1a9b0cafc1
commit 7383ed6d13
93 changed files with 8065 additions and 1098 deletions
+28
View File
@@ -0,0 +1,28 @@
import type { Part } from "@opencode-ai/sdk/v2";
const labels = { reference: "查找所需资料", data: "获取业务数据", analysis: "分析与核查", visualization: "更新地图与图表", other: "执行操作" };
const classify = (tool: string): keyof typeof labels => {
if (["skill", "read", "glob", "grep", "session_search", "memory_manager", "skill_manager"].includes(tool)) return "reference";
if (["query_project_data", "query_operational_task_data", "get_operational_task_context"].includes(tool)) return "data";
if (["render_scada_analysis", "clear_scada_analysis", "locate_features", "zoom_to_map", "show_chart", "view_history"].includes(tool)) return "visualization";
if (["publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence"].includes(tool)) return "analysis";
return "other";
};
export const createBusinessStageTracker = (write: (event: string, data: Record<string, unknown>) => void) => {
const stages = new Map<string, { startedAt: number; actions: Map<string, string> }>();
return (part: Extract<Part, { type: "tool" }>) => {
if (["final_answer", "todowrite", "todoread", "question"].includes(part.tool)) return;
const kind = classify(part.tool);
const stage = stages.get(kind) ?? { startedAt: Date.now(), actions: new Map() };
stage.actions.set(part.id, part.state.status);
stages.set(kind, stage);
const statuses = [...stage.actions.values()];
const running = statuses.some((status) => status === "pending" || status === "running");
const status = running ? "running" : statuses.includes("error") ? "error" : "completed";
write("progress", {
id: `business-${kind}`, phase: kind, status, title: labels[kind],
detail: `${statuses.filter((s) => s === "completed").length}/${statuses.length} 项操作已完成`,
started_at: stage.startedAt, ...(running ? { elapsed_ms: Date.now() - stage.startedAt } : { ended_at: Date.now(), duration_ms: Date.now() - stage.startedAt }),
});
};
};
+17 -1
View File
@@ -1,7 +1,8 @@
import { refreshSessionCredential } from "../product/credential-refresh.js";
import { type Router } from "express";
import { z } from "zod";
import { getLocalAgentContext } from "../context/localContext.js";
import { getLocalAgentContext, getTrustedProductCredential } from "../context/localContext.js";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
@@ -72,6 +73,21 @@ export const registerChatInteractionRoutes = (
sessionUiStateStore,
}: RegisterInteractionRoutesOptions,
) => {
chatRouter.post("/session/:session_id/credentials", async (req, res) => {
const record = await sessionMetadataStore.get(getLocalAgentContext(req), req.params.session_id);
const credential = getTrustedProductCredential(req);
if (!record || !credential) { res.status(404).json({ message: "session not found" }); return; }
const run = activeRuns.get(record.sessionId);
const requestId = typeof req.body?.request_id === "string" ? req.body.request_id : undefined;
if (!run || run.status !== "running" || (!requestId && record.purpose !== "task_execution")) {
res.status(409).json({ message: "credential refresh is not pending" }); return;
}
if (!refreshSessionCredential(record.sessionId, credential, requestId)) {
res.status(409).json({ message: "credential refresh request does not match" }); return;
}
res.status(202).json({ accepted: true });
});
chatRouter.post("/permission/:request_id/reply", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
+5
View File
@@ -0,0 +1,5 @@
// Shell and file mutation remain interactive even when ordinary read-only
// business tools are automatically approved. Static denials stay in OpenCode.
export function canAutomaticallyApprove(mode: string, action: string) {
return mode === "always" && !["shell", "bash", "edit", "write", "patch", "read", "external_directory", "execute", "subagent"].includes(action);
}
+5
View File
@@ -1,3 +1,4 @@
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
import { type Router } from "express";
import { z } from "zod";
@@ -576,6 +577,10 @@ export const registerChatSessionRoutes = (
},
};
run.subscribers.add(subscriber);
const pendingRefresh = credentialRefreshCoordinator.getPendingEvent(sessionRecord.sessionId);
if (pendingRefresh) subscriber.write(pendingRefresh.type, {
...pendingRefresh, session_id: sessionRecord.sessionId, request_id: pendingRefresh.requestId,
});
const cleanup = () => {
run.subscribers.delete(subscriber);
+33 -12
View File
@@ -1,4 +1,6 @@
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { canAutomaticallyApprove } from "./chatPermissionPolicy.js";
import { createBusinessStageTracker } from "./chatBusinessStages.js";
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
import { type SupportedModel } from "../chat/models.js";
@@ -187,6 +189,8 @@ export const streamPromptResponse = async ({
let promptSettled = false;
let aborted = signal?.aborted ?? false;
let failed = false;
let finalAnswer: string | undefined;
const trackBusinessStage = createBusinessStageTracker((event, data) => write(event, { ...data, session_id: clientSessionId }));
const debugContext = {
sessionId,
clientSessionId,
@@ -291,6 +295,7 @@ export const streamPromptResponse = async ({
...debugContext,
});
let pendingEvent: Promise<{ type: "event"; result: IteratorResult<OpencodeEvent> }> | null = null;
try {
while (!done) {
if (signal?.aborted) {
@@ -302,7 +307,9 @@ export const streamPromptResponse = async ({
break;
}
const nextEvent = iterator
// V2 prompt() acknowledges inbox admission before generation. Keep the same
// pending read if that acknowledgement wins the race, so no event is consumed unseen.
const nextEvent = pendingEvent ??= iterator
.next()
.then((result) => ({ type: "event" as const, result }));
const nextPrompt = promptSettled
@@ -329,6 +336,7 @@ export const streamPromptResponse = async ({
if (next.type === "prompt") {
continue;
}
pendingEvent = null;
if (next.result.done) {
break;
}
@@ -402,17 +410,18 @@ export const streamPromptResponse = async ({
patterns: event.properties.patterns,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission);
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
status: autoApprove ? "completed" : "running",
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
autoApprove
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionDetail(event),
});
if (approvalMode === "always") {
if (autoApprove) {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
@@ -447,17 +456,18 @@ export const streamPromptResponse = async ({
resources: event.properties.resources,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action);
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
status: autoApprove ? "completed" : "running",
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
autoApprove
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionV2Detail(event),
});
if (approvalMode === "always") {
if (autoApprove) {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
@@ -725,6 +735,16 @@ export const streamPromptResponse = async ({
});
}
if (part.type === "tool") {
if (part.tool === "final_answer") {
if (part.state.status === "completed") {
const output = parseToolOutput(part.state.output);
if (output && typeof output === "object" && typeof (output as Record<string, unknown>).answer === "string") {
finalAnswer = (output as { answer: string }).answer;
}
}
continue;
}
trackBusinessStage(part);
if (part.tool === "publish_execution_plan" && part.state.status === "completed" && !emittedToolParts.has(part.id)) {
const plan = executionPlanSchema.safeParse(part.state.input);
const output = parseToolOutput(part.state.output);
@@ -970,7 +990,7 @@ export const streamPromptResponse = async ({
}
await promptPromise;
if (!emittedText) {
if (!emittedText && !finalAnswer) {
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
@@ -978,6 +998,7 @@ export const streamPromptResponse = async ({
await emitFallbackMessage(runtime, sessionId, clientSessionId, tokenSmoother.writeToken);
}
await tokenSmoother.flush();
if (finalAnswer) write("final_answer", { session_id: clientSessionId, content: finalAnswer });
emitProgress({
id: "request-received",
phase: "start",
@@ -990,7 +1011,7 @@ export const streamPromptResponse = async ({
phase: "complete",
status: "completed",
title: "分析完成",
detail: emittedText
detail: (emittedText || finalAnswer)
? "最终回答已生成并推送到前端。"
: "已完成分析,并通过兜底消息补发最终回答内容。",
});
@@ -1007,7 +1028,7 @@ export const streamPromptResponse = async ({
return { aborted: false, failed: false, toolCallCount };
} finally {
await tokenSmoother.flush();
await iterator.return?.(undefined);
await iterator.return?.();
if (!promptSettled && !aborted) {
await promptPromise.catch(() => undefined);
} else if (!promptSettled) {
+4
View File
@@ -299,6 +299,10 @@ export const normalizeQuestionToolPayload = (
params: Record<string, unknown>,
clientSessionId: string,
): QuestionRequestPayload | undefined => {
// Native V2 question tools create a separate frm_ request. A call ID is not a form ID.
if ("metadata" in part.state && part.state.metadata?.native_form === true) {
return undefined;
}
if (!questionToolNames.has(part.tool)) {
return undefined;
}
+31 -10
View File
@@ -1,3 +1,4 @@
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
import { type Router } from "express";
import { randomUUID } from "node:crypto";
import {
@@ -112,8 +113,10 @@ const isUiMessage = (value: unknown): value is UIMessage =>
(value.role === "system" || value.role === "user" || value.role === "assistant") &&
Array.isArray(value.parts);
const readUiMessageText = (message: UIMessage | undefined) =>
message?.parts
const readUiMessageText = (message: UIMessage | undefined) => {
const finalPart = message?.parts.slice().reverse().find((part) => isObjectRecord(part) && part.type === "data-final_answer" && isObjectRecord(part.data) && typeof part.data.content === "string");
if (finalPart && "data" in finalPart && isObjectRecord(finalPart.data)) return String(finalPart.data.content).trim();
return message?.parts
.flatMap((part) =>
isObjectRecord(part) && part.type === "text" && typeof part.text === "string"
? [part.text]
@@ -121,6 +124,7 @@ const readUiMessageText = (message: UIMessage | undefined) =>
)
.join("")
.trim() ?? "";
};
const readLatestUserText = (messages: UIMessage[]) => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
@@ -142,10 +146,8 @@ const toUiMessages = (messages: unknown[] | undefined) =>
export const toCollaborationMessages = (messages: UIMessage[]) =>
messages.flatMap((message) => {
if (message.role !== "user" && message.role !== "assistant") return [];
const textParts = message.parts.filter(
(part) => isObjectRecord(part) && part.type === "text" && typeof part.text === "string",
);
return textParts.length > 0 ? [{ ...message, parts: textParts } as UIMessage] : [];
const content = readUiMessageText(message);
return content ? [{ ...message, parts: [{ type: "text" as const, text: content }] } as UIMessage] : [];
});
const toLegacyMessages = (messages: UIMessage[]) =>
@@ -651,6 +653,10 @@ export const registerChatStreamRoute = (
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false,
}));
} else if (event === "final_answer") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, content: typeof data.content === "string" ? data.content : message.content,
}));
} else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
@@ -826,6 +832,17 @@ export const registerChatStreamRoute = (
const unregisterFrontendActionSink = parsed.data.capabilities.includes("frontend-action@1")
? frontendActionCoordinator.registerSink(clientSessionId, (request) => publish("frontend_action", request as unknown as Record<string, unknown>))
: undefined;
let credentialFailure: Error | undefined;
const unsubscribeCredentialRefresh = credentialRefreshCoordinator.subscribe(binding.sessionId, (event) => {
publish(event.type, { ...event, session_id: clientSessionId, request_id: event.requestId });
if (event.type === "credential_refresh_failed") {
credentialFailure = new Error(event.message);
abortController.abort();
}
});
if (sessionPurpose === "task_execution") credentialRefreshCoordinator.enableBackground(binding.sessionId);
const cancelCredentialRefresh = () => credentialRefreshCoordinator.cancelSession(binding.sessionId);
abortController.signal.addEventListener("abort", cancelCredentialRefresh, { once: true });
let taskHeartbeat: ReturnType<typeof setInterval> | undefined;
let taskHeartbeatActive = true;
let taskHeartbeatPromise: Promise<void> | undefined;
@@ -897,7 +914,9 @@ export const registerChatStreamRoute = (
publish(event, data);
},
});
if (taskLeaseFailure) {
if (credentialFailure) {
publish("error", { session_id: clientSessionId, message: "会话凭据续期失败,本次执行已停止。" });
} else if (taskLeaseFailure) {
publish("error", {
session_id: clientSessionId,
message: "工况任务租约已失效,本次执行已停止,等待任务重新领取。",
@@ -920,7 +939,7 @@ export const registerChatStreamRoute = (
activeSessionRecord.taskId,
activeSessionRecord.runId,
clientSessionId,
streamResult.aborted ? "AGENT_ABORTED" : "AGENT_STREAM_FAILED",
credentialFailure ? "CREDENTIAL_REFRESH_FAILED" : streamResult.aborted ? "AGENT_ABORTED" : "AGENT_STREAM_FAILED",
streamResult.aborted ? "Agent execution was aborted" : "Agent stream failed",
);
taskRunFinalized = true;
@@ -963,7 +982,7 @@ export const registerChatStreamRoute = (
const assistantMessage = [...messages]
.reverse()
.find((message) => message.info.role === "assistant");
const assistantText = collectTextContent(assistantMessage?.parts ?? []);
const assistantText = readUiMessageText(latestUiMessages.at(-1)) || collectTextContent(assistantMessage?.parts ?? []);
const latestSessionRecord =
(await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
@@ -1046,6 +1065,8 @@ export const registerChatStreamRoute = (
}
throw error;
} finally {
unsubscribeCredentialRefresh();
abortController.signal.removeEventListener("abort", cancelCredentialRefresh);
await stopTaskHeartbeat();
await taskArtifactPublishQueue.catch(() => undefined);
unregisterFrontendActionSink?.();
@@ -1079,7 +1100,7 @@ export const registerChatStreamRoute = (
});
sessionBridge.finalizeRequest(clientSessionId);
releaseOperationalTaskCredential(binding.sessionId);
activeRun.status = taskLeaseFailure
activeRun.status = (taskLeaseFailure || credentialFailure)
? "error"
: abortController.signal.aborted
? "aborted"
+33
View File
@@ -0,0 +1,33 @@
import { type Express } from "express";
import { z } from "zod";
import { ExternalInformationClient, geocodeInput, webSearchInput } from "../product/external-information-client.js";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
export function registerExternalInformationBridge(app: Express, options: {
internalToken: string;
client?: ExternalInformationClient;
}) {
const client = options.client ?? new ExternalInformationClient();
for (const name of ["web-search", "geocode"] as const) {
app.post(`/internal/tools/${name}`, async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) { res.status(403).json({ message: "forbidden" }); return; }
const parsed = z.object({ session_id: z.string().min(1).max(128), input: name === "web-search" ? webSearchInput : geocodeInput }).strict().safeParse(req.body);
if (!parsed.success) { res.status(400).json({ message: "invalid external information input" }); return; }
const context = getRuntimeSessionContext(parsed.data.session_id);
if (!context || context.learningMode === "review") { res.status(403).json({ message: "active session context required" }); return; }
const controller = new AbortController();
const cancel = () => { if (!res.writableEnded) controller.abort(); };
res.once("close", cancel);
try {
const result = name === "web-search"
? await client.webSearch(context.sessionId, webSearchInput.parse(parsed.data.input), controller.signal)
: await client.geocode(context.sessionId, geocodeInput.parse(parsed.data.input), controller.signal);
if (!controller.signal.aborted) res.json(result);
} catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502;
if (!controller.signal.aborted) res.status(status).json({ message: error instanceof Error ? error.message : "external information unavailable" });
} finally { res.removeListener("close", cancel); }
});
}
}
+54
View File
@@ -0,0 +1,54 @@
import { type Express } from "express";
import { z } from "zod";
import { executeMemoryManager, executeSkillManager } from "../learning/toolManagers.js";
import { type MemoryStore } from "../memory/store.js";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { type SkillStore } from "../skills/store.js";
const memoryInput = z.object({
action: z.enum(["add", "list", "replace", "remove"]),
scope: z.string().max(32),
reason: z.string().max(2000).optional(),
content: z.string().max(20000).optional(),
target_id: z.string().max(128).optional(),
}).strict();
const skillInput = z.object({
action: z.enum(["list", "write_skill", "remove_skill", "append_pattern", "remove_pattern", "write_reference", "remove_reference", "write_script", "remove_script"]),
skill_path: z.string().max(512),
reason: z.string().max(2000).optional(),
content: z.string().max(200000).optional(),
file_path: z.string().max(512).optional(),
pattern: z.string().max(20000).optional(),
target_id: z.string().max(128).optional(),
}).strict();
export const registerLearningToolBridge = (app: Express, options: {
internalToken: string;
memoryStore: MemoryStore;
skillStore: SkillStore;
}) => {
for (const name of ["memory-manager", "skill-manager"] as const) {
app.post(`/internal/tools/${name}`, async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) {
res.status(403).json({ message: "forbidden" });
return;
}
const context = typeof req.body?.session_id === "string"
? getRuntimeSessionContext(req.body.session_id) : null;
if (!context) {
res.status(404).json({ message: "session context not found" });
return;
}
if (name === "memory-manager") {
const input = memoryInput.safeParse(req.body.input);
if (!input.success) { res.status(400).json({ message: "invalid memory input" }); return; }
res.json(await executeMemoryManager(options.memoryStore, context, input.data));
} else {
const input = skillInput.safeParse(req.body.input);
if (!input.success) { res.status(400).json({ message: "invalid skill input" }); return; }
res.json(await executeSkillManager(options.skillStore, context, input.data));
}
});
}
};
+80
View File
@@ -0,0 +1,80 @@
import { readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { type Express } from "express";
import { z } from "zod";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { executeSandboxCommand } from "../sandbox/landlockSandbox.js";
import { setSandboxOwnership } from "../runtime/conversationWorkspace.js";
export const sandboxInput = z.object({
session_id: z.string().min(1).max(128),
call_id: z.string().min(1).max(128),
message_id: z.string().min(1).max(128),
command: z.string().min(1).max(20000),
description: z.string().max(500).optional(),
timeout: z.number().int().min(1).max(1800).default(120),
}).strict();
// The replacement tool must retain the service's static command denials as
// well as the native approval gate. Command parsing is not the OS boundary.
export function isShellCommandDenied(command: string): boolean {
const rules = (JSON.parse(readFileSync("opencode.json", "utf8")) as {
permissions: { action: string; resource: string; effect: string }[];
}).permissions;
let effect = "ask";
for (const rule of rules) {
if (rule.action !== "shell" && rule.action !== "*") continue;
const pattern = rule.resource.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replaceAll("*", ".*").replaceAll("?", ".");
if (new RegExp(`^${pattern}$`, "s").test(command)) effect = rule.effect;
}
return effect === "deny";
}
export function registerSandboxToolBridge(app: Express, options: {
internalToken: string;
runtime: {
ensureSessionWorkspace(sessionId: string): Promise<string>;
approveSandboxShell(input: { sessionId: string; command: string; description?: string; callId: string; messageId: string }, signal: AbortSignal): Promise<void>;
};
}) {
app.post("/internal/tools/sandbox-shell", async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) {
res.status(403).json({ message: "forbidden" }); return;
}
const parsed = sandboxInput.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ message: "invalid sandbox input" }); return; }
const context = getRuntimeSessionContext(parsed.data.session_id);
if (!context) { res.status(404).json({ message: "session context not found" }); return; }
if (context.learningMode === "review" || isShellCommandDenied(parsed.data.command)) {
res.status(403).json({ message: "shell command denied" }); return;
}
const controller = new AbortController();
const cancel = () => { if (!res.writableEnded) controller.abort(); };
res.once("close", cancel);
try {
const workspace = await options.runtime.ensureSessionWorkspace(context.sessionId);
await options.runtime.approveSandboxShell({
sessionId: context.sessionId, command: parsed.data.command, description: parsed.data.description,
callId: parsed.data.call_id, messageId: parsed.data.message_id,
}, controller.signal);
const result = await executeSandboxCommand(workspace, parsed.data.command, parsed.data.timeout, controller.signal);
const files: Partial<Record<"stdout_file" | "stderr_file", string>> = {};
for (const stream of ["stdout", "stderr"] as const) {
if (Buffer.byteLength(result[stream]) <= 3000) continue;
const path = resolve(workspace, "files", `shell-${randomUUID()}-${stream}.txt`);
await writeFile(path, result[stream], { flag: "wx", mode: 0o600 });
await setSandboxOwnership(path);
files[`${stream}_file`] = path;
result[stream] = Buffer.from(result[stream]).subarray(0, 3000).toString("utf8");
}
if (!controller.signal.aborted) res.json({ ...result, ...files, workspace_directory: workspace, cwd: `${workspace}/files` });
} catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 503;
if (!controller.signal.aborted) res.status(status).json({ message: error instanceof Error ? error.message : "sandbox unavailable" });
} finally { res.removeListener("close", cancel); }
});
}