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
+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); }
});
}