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