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
+109
View File
@@ -0,0 +1,109 @@
import { lstatSync, realpathSync } from "node:fs";
import { lstat, readdir } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
import { type Plugin } from "@opencode/plugin";
import { z } from "zod";
import { getInternalBridgeConfig } from "./internalBridge.js";
type Context = Parameters<Plugin.Plugin["setup"]>[0];
const inside = (root: string, path: string) => {
const value = relative(root, path);
return value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value);
};
const canonical = (path: string): string => {
let existing = path;
const exists = (candidate: string) => { try { lstatSync(candidate); return true; } catch { return false; } };
while (!exists(existing)) {
const parent = dirname(existing);
if (parent === existing) throw new Error("invalid path");
existing = parent;
}
return resolve(realpathSync(existing), relative(existing, path));
};
export function canAccessConversationPath(workspace: string, path: string, edit = false, skills?: string) {
try {
const target = canonical(resolve(workspace, path));
const scopedPath = inside(workspace, target) ? relative(workspace, target) : path;
if (scopedPath.split(/[\\/]/).some((name) => name.includes(".env") || name === "data" || name === "logs")) return false;
if (!edit && skills && inside(canonical(skills), target)) return true;
if (!inside(workspace, target)) return false;
return !edit || ["files", "tmp", ".cache"].some((name) => inside(resolve(workspace, name), target));
} catch { return false; }
}
export async function registerSessionSandbox(ctx: Context) {
const root = resolve(process.env.TJWATER_AGENT_WORKSPACE_ROOT ?? ".agent-workspaces");
const skills = resolve(process.env.OPENCODE_SKILLS_ROOT_DIR ?? ".opencode/skills");
const workspace = async (sessionID: string) => {
const session = await ctx.session.get({ sessionID });
const directory = canonical(session.location.directory);
if (dirname(directory) !== canonical(root) || !directory.split(sep).at(-1)?.startsWith("conversation-")) {
throw new Error("session has no managed conversation workspace");
}
return directory;
};
await ctx.tool.transform((editor) => {
// A later add replaces the native executor for both direct and Code Mode calls.
editor.remove("shell");
editor.add({
name: "shell",
description: "在当前会话的 Landlock + seccomp 沙箱运行 Shell。工作目录为会话的 files/;不能联网、读取密钥、源码或其他会话。后台进程不保留。业务查询使用类型化工具。",
input: z.object({
command: z.string().min(1).max(20000),
description: z.string().max(500).optional(),
timeout: z.number().int().min(1).max(1800).optional().describe("Timeout in seconds, default 120."),
}).strict(),
options: { codemode: false, permission: "shell" },
async execute(input, context) {
await workspace(context.sessionID);
const { baseUrl, internalToken } = getInternalBridgeConfig();
const response = await fetch(`${baseUrl}/internal/tools/sandbox-shell`, {
method: "POST", signal: context.signal,
headers: { "Content-Type": "application/json", "x-agent-internal-token": internalToken },
body: JSON.stringify({ ...input, session_id: context.sessionID, call_id: context.id, message_id: context.messageID }),
});
const text = await response.text();
if (!response.ok) throw new Error(`sandbox shell failed (${response.status}): ${text}`);
return { content: text, metadata: { sandbox: "landlock-seccomp" } };
},
});
for (const name of ["glob", "grep"]) {
editor.update(name, (tool) => {
const execute = tool.execute;
tool.execute = async (input, context) => {
const directory = await workspace(context.sessionID);
const path = (input as { path?: string }).path ?? directory;
if (!canAccessConversationPath(directory, path, false, skills)) throw new Error("search outside conversation workspace denied");
// Native recursive search does not ask 'read' permission per match.
// Do not allow a recursive search tree containing links or secrets.
const pending = [canonical(resolve(directory, path))];
while (pending.length) {
const current = pending.pop()!;
if (!canAccessConversationPath(directory, current, false, skills) || (await lstat(current)).isSymbolicLink()) throw new Error("unsafe search tree denied");
if ((await lstat(current)).isDirectory()) for (const entry of await readdir(current)) pending.push(resolve(current, entry));
}
return execute(input, context);
};
});
}
});
// Close alternate native Shell APIs as well. Only the authenticated bridge
// above may spawn commands, with kernel restrictions already installed.
await ctx.shell.hook("create.before", () => { throw new Error("host shell disabled; use the sandbox shell tool"); });
await ctx.permission.hook("evaluate", async (event) => {
if (event.effect === "deny") return;
if (event.action === "shell") { event.effect = "ask"; return; }
if (event.action === "read" || event.action === "edit" || event.action === "external_directory") {
try {
const directory = await workspace(event.sessionID);
if (event.resources.some((path) => !canAccessConversationPath(directory, path.replace(/\/\*$/, ""), event.action === "edit", skills))) {
event.effect = "deny";
} else if (event.action === "edit") event.effect = "ask";
} catch { event.effect = "deny"; }
}
});
await ctx.session.hook("context", async (event) => {
const directory = await workspace(event.sessionID);
event.system.push({ type: "text", text: `当前会话目录:${directory}。Shell 的工作目录是 ${directory}/files。写入与计算文件放在 files/;目录根只读。shell 禁止网络与跨会话访问,业务数据、网页搜索和地理编码使用类型化工具。` });
});
}