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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user