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
+242
View File
@@ -0,0 +1,242 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { lstat, mkdir, realpath, rm } from "node:fs/promises";
import { resolve } from "node:path";
import { config } from "../config.js";
import {
resolveConversationWorkspace,
createConversationWorkspace,
SANDBOX_GID,
SANDBOX_UID,
setSandboxOwnership,
} from "../runtime/conversationWorkspace.js";
export type SandboxProbe = {
landlockAbi: number;
seccomp: boolean;
};
export type SandboxExecutionResult = {
exitCode: number | null;
outcome: "completed" | "output_limit" | "timeout" | "cancelled";
signal: NodeJS.Signals | null;
stderr: string;
stderrTruncated: boolean;
stdout: string;
};
const sandboxRunnerPath = resolve("scripts/landlock_sandbox.py");
const maxSandboxTimeoutSeconds = 30 * 60;
const resolvePythonPath = () => {
const virtualEnvironment = process.env.VIRTUAL_ENV?.trim();
const candidates = [
virtualEnvironment ? resolve(virtualEnvironment, "bin/python") : "",
"/opt/venv/bin/python",
"/usr/bin/python3",
];
return candidates.find((candidate) => candidate && existsSync(candidate)) ?? "python3";
};
const collectProcess = async (
args: string[],
options: {
cwd?: string;
env?: NodeJS.ProcessEnv;
maxStderrBytes: number;
maxStdoutBytes: number;
timeoutMs: number;
signal?: AbortSignal;
},
): Promise<SandboxExecutionResult> => {
const child = spawn(resolvePythonPath(), [sandboxRunnerPath, ...args], {
cwd: options.cwd,
detached: true,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let stderrTruncated = false;
let outcome: SandboxExecutionResult["outcome"] = "completed";
let terminationStarted = false;
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
const killGroup = (signal: NodeJS.Signals) => {
if (child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Fall back to the direct child when process groups are unavailable.
}
}
child.kill(signal);
};
// The foreground shell can exit before descendants close inherited pipes.
// Reap its group on exit, rather than waiting for pipe close or the timeout.
child.once("exit", () => killGroup("SIGKILL"));
const terminate = (nextOutcome: "output_limit" | "timeout" | "cancelled") => {
if (terminationStarted) return;
terminationStarted = true;
outcome = nextOutcome;
killGroup("SIGTERM");
forceKillTimer = setTimeout(() => killGroup("SIGKILL"), 1500);
};
const cancel = () => terminate("cancelled");
options.signal?.addEventListener("abort", cancel, { once: true });
if (options.signal?.aborted) cancel();
const timeoutTimer = setTimeout(() => terminate("timeout"), options.timeoutMs);
child.stdout.on("data", (chunk: Buffer) => {
if (terminationStarted) return;
if (stdoutBytes + chunk.length > options.maxStdoutBytes) {
terminate("output_limit");
return;
}
stdoutChunks.push(chunk);
stdoutBytes += chunk.length;
});
child.stderr.on("data", (chunk: Buffer) => {
if (stderrTruncated) return;
const remaining = options.maxStderrBytes - stderrBytes;
if (chunk.length > remaining) {
if (remaining > 0) {
stderrChunks.push(chunk.subarray(0, remaining));
stderrBytes += remaining;
}
stderrTruncated = true;
return;
}
stderrChunks.push(chunk);
stderrBytes += chunk.length;
});
return await new Promise((resolveResult, reject) => {
child.once("error", (error) => {
options.signal?.removeEventListener("abort", cancel);
clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
reject(error);
});
child.once("close", (exitCode, signal) => {
options.signal?.removeEventListener("abort", cancel);
clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
resolveResult({
exitCode,
outcome,
signal,
stderr: Buffer.concat(stderrChunks, stderrBytes).toString("utf8"),
stderrTruncated,
stdout:
outcome === "output_limit"
? ""
: Buffer.concat(stdoutChunks, stdoutBytes).toString("utf8"),
});
});
});
};
export const probeLandlockSandbox = async (): Promise<SandboxProbe> => {
const result = await collectProcess(["--probe"], {
maxStderrBytes: 16 * 1024,
maxStdoutBytes: 16 * 1024,
timeoutMs: 5000,
});
if (result.exitCode !== 0) {
throw new Error(result.stderr || "Landlock sandbox probe failed");
}
const payload = JSON.parse(result.stdout) as {
landlock_abi?: unknown;
seccomp?: unknown;
};
if (
typeof payload.landlock_abi !== "number" ||
payload.landlock_abi < 4 ||
payload.seccomp !== true
) {
throw new Error("Landlock ABI 4 and seccomp are required");
}
const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
try {
const execution = await executeSandboxCommand(directory, "python3 -c 'import socket;\ntry: socket.socket()\nexcept PermissionError: print(\"sandbox-probe-ok\")\nelse: raise RuntimeError(\"network filter not installed\")'", 5);
if (execution.exitCode !== 0 || !execution.stdout.includes("sandbox-probe-ok")) {
throw new Error(execution.stderr || "sandbox execution probe failed");
}
} finally { await rm(directory, { recursive: true }); }
return { landlockAbi: payload.landlock_abi, seccomp: true };
};
const buildSandboxEnvironment = (workspace: string): NodeJS.ProcessEnv => ({
HOME: workspace,
LANG: process.env.LANG ?? "C.UTF-8",
LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
MPLCONFIGDIR: resolve(workspace, ".cache/matplotlib"),
PATH: "/opt/venv/bin:/usr/local/bin:/usr/bin:/bin",
PYTHONDONTWRITEBYTECODE: "1",
TMPDIR: resolve(workspace, "tmp"),
TZ: process.env.TZ ?? "Asia/Shanghai",
VIRTUAL_ENV: "/opt/venv",
XDG_CACHE_HOME: resolve(workspace, ".cache"),
});
export const executeSandboxCommand = async (
workspaceDirectory: string,
command: string,
timeoutSeconds: number,
signal?: AbortSignal,
): Promise<SandboxExecutionResult> => {
const workspace = await resolveConversationWorkspace(
workspaceDirectory,
config.CONVERSATION_WORKSPACE_ROOT,
);
const normalizedTimeout = Math.min(
maxSandboxTimeoutSeconds,
Math.max(1, Math.trunc(timeoutSeconds)),
);
for (const path of [resolve(workspace, "files"), resolve(workspace, "tmp"), resolve(workspace, ".cache")]) {
await mkdir(path, { mode: 0o700, recursive: true });
if ((await lstat(path)).isSymbolicLink() || await realpath(path) !== path) {
throw new Error("sandbox writable directory must not be a symbolic link");
}
await setSandboxOwnership(path);
}
const readOnlyPaths = [
"/usr",
"/bin",
"/lib",
"/lib64",
"/opt/venv",
"/etc/ld.so.cache",
"/etc/localtime",
"/etc/group",
"/etc/nsswitch.conf",
"/etc/passwd",
resolve(config.OPENCODE_SKILLS_ROOT_DIR),
].filter(existsSync);
const args = [
"--workspace",
workspace,
"--uid",
String(SANDBOX_UID),
"--gid",
String(SANDBOX_GID),
...readOnlyPaths.flatMap((path) => ["--read-only", path]),
...["files", "tmp", ".cache"].flatMap((name) => ["--writable", resolve(workspace, name)]),
"--command",
command,
];
return collectProcess(args, {
cwd: resolve(workspace, "files"),
env: buildSandboxEnvironment(workspace),
maxStderrBytes: 256 * 1024,
maxStdoutBytes: config.SANDBOX_MAX_OUTPUT_BYTES,
timeoutMs: normalizedTimeout * 1000,
signal,
});
};