Files
jiang 7383ed6d13 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.
2026-10-08 15:46:37 +08:00

58 lines
3.7 KiB
TypeScript

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`);
});
});