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
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "bun:test";
import { createBusinessStageTracker } from "../../src/routes/chatBusinessStages.js";
import { streamPromptResponse } from "../../src/routes/chatStream.js";
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
describe("business stages and final answer", () => {
it("keeps a stage running while parallel actions remain, then preserves errors", () => {
const events: Record<string, unknown>[] = [];
const track = createBusinessStageTracker((_event, data) => events.push(data));
const part = (id: string, status: string) => ({ id, type: "tool", tool: "query_project_data", state: { status } }) as Parameters<typeof track>[0];
track(part("a", "running")); track(part("b", "running")); track(part("a", "completed"));
expect(events.at(-1)?.status).toBe("running");
track(part("b", "error"));
expect(events.at(-1)).toMatchObject({ id: "business-data", status: "error" });
});
it("buffers final_answer until the runtime succeeds and does not end the run at the tool", async () => {
const output: string[] = [];
const events = [
{ type: "message.part.updated", properties: { sessionID: "s", part: { id: "f", type: "tool", tool: "final_answer", state: { status: "completed", input: { answer: "最终结论" }, output: JSON.stringify({ answer: "最终结论" }) } } } },
{ type: "message.part.updated", properties: { sessionID: "s", part: { id: "q", type: "tool", tool: "query_project_data", state: { status: "completed", input: { source: "scada_devices" }, output: "{}" } } } },
{ type: "session.idle", properties: { sessionID: "s" } },
];
const runtime = { prompt: async () => undefined, subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { yield* events; } }), messages: async () => { throw new Error("final answer needs no text fallback"); } } as unknown as OpencodeRuntimeAdapter;
const result = await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "c", message: "test", write: (event, data) => { output.push(event); if (event === "final_answer") expect(data.content).toBe("最终结论"); } });
expect(result.failed).toBeFalse();
expect(output.indexOf("tool_call")).toBeLessThan(output.indexOf("final_answer"));
expect(output.at(-1)).toBe("done");
});
});
+67 -2
View File
@@ -19,6 +19,26 @@ const createEventStream = (events: unknown[]) => ({
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("streamPromptResponse", () => {
it("keeps the first pending event when the V2 prompt acknowledgement arrives first", async () => {
const emitted: { event: string; data: Record<string, unknown> }[] = [];
const runtime = {
prompt: async () => undefined,
subscribeEvents: async () => ({
async *[Symbol.asyncIterator]() {
await sleep(5);
yield { type: "message.part.updated", properties: { sessionID: "s", part: { id: "text", type: "text", text: "", sessionID: "s", messageID: "m" } } };
yield { type: "message.part.delta", properties: { sessionID: "s", partID: "text", field: "text", delta: "流事件完整" } };
yield { type: "session.idle", properties: { sessionID: "s" } };
},
}),
messages: async () => { throw new Error("lost streamed events caused a fallback read"); },
} as unknown as OpencodeRuntimeAdapter;
const result = await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "test", write: (event, data) => emitted.push({ event, data }) });
expect(result.failed).toBeFalse();
expect(emitted.filter((e) => e.event === "token").map((e) => e.data.content).join("")).toBe("流事件完整");
expect(emitted.at(-1)?.event).toBe("done");
});
it("does not split short Chinese text only because it has punctuation", () => {
expect(detectTokenSmoothingChunk("SCADA 液位异常,需要继续分析", "zh")).toBeNull();
expect(detectTokenSmoothingChunk("SCADA 液位异常需要继续分析。", "zh")).toBeNull();
@@ -222,7 +242,7 @@ describe("streamPromptResponse", () => {
} satisfies Partial<PermissionRequestPayload>);
});
it("auto replies always when approval mode is always", async () => {
it("auto approves ordinary business tools in always mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
@@ -232,7 +252,7 @@ describe("streamPromptResponse", () => {
properties: {
id: "perm-1",
sessionID: "runtime-session-1",
permission: "bash",
permission: "web_search",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
@@ -277,6 +297,51 @@ describe("streamPromptResponse", () => {
});
});
it("requires manual Shell approval even in always mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
},
},
{
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => {
replies.push(options);
},
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "always",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([]);
expect(events.some((item) => item.event === "permission_request")).toBe(true);
expect(events.some((item) => item.event === "permission_response")).toBe(false);
});
it("forwards opencode v2 permission requests as SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
@@ -0,0 +1,55 @@
import { 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 { attachTrustedAgentContext } from "../../src/context/localContext.js";
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
import { bindSessionCredential, credentialRefreshCoordinator, releaseSessionCredential } from "../../src/product/credential-refresh.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
import type { ActiveRun } from "../../src/routes/chatUiState.js";
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
describe("credential refresh HTTP route", () => {
it("binds fresh headers only after validating the session owner, project and pending request", async () => {
const directory = await mkdtemp(join(tmpdir(), "credential-route-"));
const originalFetch = globalThis.fetch;
const metadata = new SessionMetadataStore(join(directory, "metadata"));
await metadata.initialize();
const projectId = "10000000-0000-4000-8000-000000000001";
await metadata.ensure({ sessionId: "credential-route-session", actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${projectId}`), projectId, userId: "owner" });
globalThis.fetch = (async (_url, init) => {
const headers = new Headers(init?.headers);
return Response.json({ user_id: headers.get("Authorization") === "Bearer TEST_OWNER_NEW" ? "owner" : "other", project_id: headers.get("X-Project-Id"), network: "supply", token_expires_at: new Date(Date.now() + 300000).toISOString() });
}) as typeof fetch;
const app = express(); app.use(express.json());
const router = express.Router();
router.use((req, res, next) => { void attachTrustedAgentContext(req).then(() => next()).catch(() => res.sendStatus(401)); });
const activeRuns = new Map<string, ActiveRun>([["credential-route-session", { clientSessionId: "credential-route-session", controller: new AbortController(), messages: [], pendingPermissions: new Map(), pendingQuestions: new Map(), subscribers: new Set(), status: "running" }]]);
registerChatInteractionRoutes(router, { activeRuns, runtime: {} as OpencodeRuntimeAdapter, sessionMetadataStore: metadata, sessionUiStateStore: new SessionUiStateStore(join(directory, "ui")) });
app.use(router);
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
bindSessionCredential("credential-route-session", { authorization: "Bearer TEST_OLD", projectId });
const unsubscribe = credentialRefreshCoordinator.subscribe("credential-route-session", () => {});
const pending = credentialRefreshCoordinator.request("credential-route-session", "access_token_rejected");
const requestId = credentialRefreshCoordinator.getPendingRequestId("credential-route-session")!;
const post = (authorization: string, id = requestId, project = projectId) => originalFetch(`${base}/session/credential-route-session/credentials`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: authorization, "X-Project-Id": project }, body: JSON.stringify({ request_id: id }) });
try {
expect((await post("Bearer TEST_OTHER")).status).toBe(404);
expect((await post("Bearer TEST_OWNER_NEW", requestId, "other-project")).status).toBe(404);
expect((await post("Bearer TEST_OWNER_NEW", "stale-request")).status).toBe(409);
expect((await post("Bearer TEST_OWNER_NEW")).status).toBe(202);
expect(await pending).toMatchObject({ authorization: "Bearer TEST_OWNER_NEW", projectId });
} finally {
unsubscribe(); releaseSessionCredential("credential-route-session");
await pending.catch(() => undefined);
globalThis.fetch = originalFetch;
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(directory, { force: true, recursive: true });
}
});
});
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it } from "bun:test";
import express from "express";
import { type Server } from "node:http";
import { registerExternalInformationBridge } from "../../src/routes/externalInformationBridge.js";
import { ExternalInformationClient } from "../../src/product/external-information-client.js";
import { bindSessionCredential, releaseSessionCredential } from "../../src/product/credential-refresh.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
let agent: Server | undefined;
let backend: Server | undefined;
const session = "external-information-bridge-test";
afterEach(async () => {
for (const server of [agent, backend]) if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
releaseSessionCredential(session); removeRuntimeSessionContext(session);
});
describe("external information bridge", () => {
it("calls search and geocode with the owning session and rejects browser, scope and URL injection", async () => {
let calls = 0;
const product = express(); product.use(express.json());
product.post(["/api/v1/web-searches", "/api/v1/geocoding-requests"], (req, res) => {
calls++;
expect(req.header("Authorization")).toBe("Bearer TEST_ONLY");
expect(req.header("X-Project-Id")).toBe("trusted-project");
res.json(req.path.endsWith("web-searches") ? { data: { results: [req.body.query] } } : { status: "0", location: { lon: "116.4", lat: "39.9" } });
});
backend = product.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => backend!.once("listening", resolve));
const app = express(); app.use(express.json());
registerExternalInformationBridge(app, { internalToken: "TEST_ONLY", client: new ExternalInformationClient(`http://127.0.0.1:${(backend.address() as { port: number }).port}`) });
agent = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => agent!.once("listening", resolve));
const base = `http://127.0.0.1:${(agent.address() as { port: number }).port}`;
const post = (name: string, body: unknown, headers: Record<string, string> = {}) => fetch(`${base}/internal/tools/${name}`, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "TEST_ONLY", ...headers }, body: JSON.stringify(body) });
setRuntimeSessionContext({ sessionId: session, clientSessionId: session, actorKey: "owner", projectKey: "trusted-project", traceId: "test", purpose: "ordinary_chat" });
bindSessionCredential(session, { authorization: "Bearer TEST_ONLY", projectId: "trusted-project" });
expect((await post("web-search", { session_id: session, input: { query: "政策" } })).status).toBe(200);
const coordinates = await post("geocode", { session_id: session, input: { keyword: "北京" } });
expect(await coordinates.json()).toMatchObject({ center: [116.4, 39.9] });
expect((await post("web-search", { session_id: session, input: { query: "test" } }, { Origin: "http://browser" })).status).toBe(403);
expect((await post("web-search", { session_id: "missing", input: { query: "test" } })).status).toBe(403);
expect((await post("web-search", { session_id: session, input: { query: "test", url: "http://untrusted" } })).status).toBe(400);
expect((await post("geocode", { session_id: session, input: { keyword: "北京" }, project_id: "other" })).status).toBe(400);
expect(calls).toBe(2);
releaseSessionCredential(session);
expect((await post("geocode", { session_id: session, input: { keyword: "北京" } })).status).toBe(401);
expect(calls).toBe(2);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "bun:test";
import express from "express";
import { type Server } from "node:http";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { config } from "../../src/config.js";
import { createConversationWorkspace } from "../../src/runtime/conversationWorkspace.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { registerSandboxToolBridge } from "../../src/routes/sandboxToolBridge.js";
let server: Server | undefined;
let root: string | undefined;
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
const session = "sandbox-bridge-test";
afterEach(async () => {
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
if (root) await rm(root, { recursive: true, force: true });
config.CONVERSATION_WORKSPACE_ROOT = previousRoot;
removeRuntimeSessionContext(session);
});
describe("sandbox execution bridge", () => {
it("executes only after approval and cannot override the owning workspace", async () => {
root = await mkdtemp(join(tmpdir(), "sandbox-bridge-test-"));
config.CONVERSATION_WORKSPACE_ROOT = root;
const directory = await createConversationWorkspace(root);
setRuntimeSessionContext({ sessionId: session, clientSessionId: session, actorKey: "owner", projectKey: "project", traceId: "test" });
let approve!: () => void;
let approved = false;
let ready!: () => void;
const requested = new Promise<void>((resolve) => { ready = resolve; });
const app = express(); app.use(express.json());
registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: {
ensureSessionWorkspace: async (id) => { expect(id).toBe(session); return directory; },
approveSandboxShell: async (input) => { if (approved) return; expect(input.command).toContain("approved.txt"); ready(); await new Promise<void>((resolve) => { approve = resolve; }); approved = true; },
} });
server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server!.once("listening", resolve));
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/internal/tools/sandbox-shell`;
const body = { session_id: session, call_id: "call-test", message_id: "message-test", command: "printf approved > approved.txt; cat approved.txt" };
const post = (input: unknown, headers: Record<string, string> = {}) => fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "TEST_ONLY", ...headers }, body: JSON.stringify(input) });
expect((await post(body, { Origin: "http://browser" })).status).toBe(403);
expect((await post({ ...body, workspace_directory: "/tmp/other" })).status).toBe(400);
expect((await post({ ...body, session_id: "missing" })).status).toBe(404);
expect((await post({ ...body, command: "cat /app/.env" })).status).toBe(403);
const response = post(body);
await requested;
expect(await Bun.file(join(directory, "files", "approved.txt")).exists()).toBeFalse();
approve();
expect(await (await response).json()).toMatchObject({ stdout: "approved", exitCode: 0, cwd: join(directory, "files") });
expect(await readFile(join(directory, "files", "approved.txt"), "utf8")).toBe("approved");
const large = await (await post({ ...body, command: "python3 -c 'print(\"x\" * 10000)'" })).json() as { stdout: string; stdout_file: string };
expect(Buffer.byteLength(large.stdout)).toBe(3000);
expect(large.stdout_file.startsWith(join(directory, "files"))).toBeTrue();
expect(await readFile(large.stdout_file, "utf8")).toBe(`${"x".repeat(10000)}\n`);
});
});