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.
243 lines
7.6 KiB
TypeScript
243 lines
7.6 KiB
TypeScript
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,
|
|
});
|
|
};
|