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:
@@ -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); }
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user