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
+4 -4
View File
@@ -2,11 +2,9 @@ import { describe, expect, it } from "bun:test";
import { existsSync } from "node:fs";
const disabledServerTools = [
"geocode",
"get_project_context",
"list_scada_assets",
"query_scada_readings",
"web_search",
];
describe("disabled opencode tools", () => {
@@ -15,10 +13,12 @@ describe("disabled opencode tools", () => {
expect(existsSync(`.opencode/tools/${toolName}.ts`)).toBeFalse();
}
expect(existsSync(".opencode/tools/server_api_shared.ts")).toBeFalse();
expect(existsSync(".opencode/tools/zoom_to_map.ts")).toBeFalse();
expect(existsSync(".opencode/tools/web_search.ts")).toBeTrue();
expect(existsSync(".opencode/tools/geocode.ts")).toBeTrue();
expect(existsSync(".opencode/tools/zoom_to_map.ts")).toBeTrue();
expect(existsSync(".opencode/tools/frontend_action.ts")).toBeTrue();
expect(existsSync(".opencode/tools/render_scada_analysis.ts")).toBeTrue();
expect(existsSync(".opencode/tools/clear_scada_analysis.ts")).toBeTrue();
expect(existsSync(".opencode/plugins/frontend-action-call-id.ts")).toBeTrue();
expect(existsSync(".opencode/plugins/tjwater-tools.ts")).toBeTrue();
});
});
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it } from "bun:test";
import express from "express";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type Server } from "node:http";
import { registerLearningToolBridge } from "../../src/routes/learningToolBridge.js";
import { MemoryStore } from "../../src/memory/store.js";
import { SkillStore } from "../../src/skills/store.js";
import { getRuntimeSessionContext, removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
let server: Server | undefined;
let directory: string | undefined;
afterEach(async () => {
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
if (directory) await rm(directory, { recursive: true, force: true });
removeRuntimeSessionContext("learning-bridge-session");
});
describe("learning tools across process boundaries", () => {
it("uses the parent session scope for list-before-write and rejects forged scope or missing context", async () => {
directory = await mkdtemp(join(tmpdir(), "learning-bridge-"));
const memoryStore = new MemoryStore(join(directory, "memory"), join(directory, "backup"));
await memoryStore.initialize();
setRuntimeSessionContext({ sessionId: "learning-bridge-session", clientSessionId: "client", actorKey: "owner", projectKey: "project", traceId: "test", allowLearningWrite: true });
const app = express(); app.use(express.json());
registerLearningToolBridge(app, { internalToken: "TEST_ONLY", memoryStore, skillStore: new SkillStore(join(directory, "skills"), join(directory, "skills-backup")) });
server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server!.once("listening", resolve));
const address = server.address() as { port: number };
const baseUrl = `http://127.0.0.1:${address.port}`;
const child = Bun.spawn([process.execPath, "-e", [
'import memory from "./.opencode/tools/memory_manager.ts";',
'import skill from "./.opencode/tools/skill_manager.ts";',
'const ctx = { sessionID: "learning-bridge-session" };',
'const call = (action, content) => memory.execute({ action, scope: "user", reason: "test", content }, ctx).then(JSON.parse);',
'const before = await call("add", "用户偏好简洁答复");',
'await call("list"); const added = await call("add", "用户偏好简洁答复");',
'const skills = JSON.parse(await skill.execute({ action: "list", skill_path: "__root__", reason: "test" }, ctx));',
'process.stdout.write(JSON.stringify({ before, added, skills }));',
].join("\n")], { cwd: process.cwd(), env: { ...process.env, AGENT_INTERNAL_TOKEN: "TEST_ONLY", TJWATER_AGENT_INTERNAL_BASE_URL: baseUrl }, stdout: "pipe", stderr: "pipe" });
const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" });
const result = JSON.parse(stdout);
expect(result.before.decision).toBe("rejected");
expect(result.added.decision).toBe("accepted");
expect(result.skills.kind).toBe("skill");
expect(getRuntimeSessionContext("learning-bridge-session")?.memoryListReadScopes?.user).toBeTrue();
expect(await memoryStore.list("user", "owner")).toHaveLength(1);
expect(await memoryStore.list("user", "other-owner")).toHaveLength(0);
const post = (sessionId: string, input: unknown, token = "TEST_ONLY") => fetch(`${baseUrl}/internal/tools/memory-manager`, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": token }, body: JSON.stringify({ session_id: sessionId, input }) });
expect((await post("missing", { action: "list", scope: "user" })).status).toBe(404);
expect((await post("learning-bridge-session", { action: "list", scope: "user", actorKey: "other-owner" })).status).toBe(400);
expect((await post("learning-bridge-session", { action: "list", scope: "user" }, "wrong")).status).toBe(403);
});
});
+4 -1
View File
@@ -3,7 +3,10 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createSkillManagerTool } from "../../.opencode/tools/skill_manager.js";
import { executeSkillManager, type SkillManagerInput } from "../../src/learning/toolManagers.js";
const createSkillManagerTool = (store: SkillStore, reader: { read(sessionId: string): RuntimeSessionContext | null }, _ready: Promise<unknown>) => ({
execute: async (args: SkillManagerInput & { reason: string }, context: { sessionID: string }) => JSON.stringify(await executeSkillManager(store, reader.read(context.sessionID)!, args)),
});
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { SkillStore } from "../../src/skills/store.js";