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