import { afterEach, describe, expect, it } from "bun:test"; import express from "express"; import { type Server } from "node:http"; import { mkdtemp, mkdir, readFile, rm, writeFile, symlink } 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 previousDataHome = process.env.XDG_DATA_HOME; const previousRoot = config.CONVERSATION_WORKSPACE_ROOT; const session = "sandbox-bridge-test"; afterEach(async () => { if (server) await new Promise((resolve) => server!.close(() => resolve())); if (root) await rm(root, { recursive: true, force: true }); config.CONVERSATION_WORKSPACE_ROOT = previousRoot; removeRuntimeSessionContext(session); if (previousDataHome === undefined) delete process.env.XDG_DATA_HOME; else process.env.XDG_DATA_HOME = previousDataHome; }); 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((resolve) => { ready = resolve; }); const app = express(); app.use(express.json()); registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: { messages: async () => [], 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((resolve) => { approve = resolve; }); approved = true; }, } }); server = app.listen(0, "127.0.0.1"); await new Promise((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 = {}) => 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`); }); }); it("stages only this session's native output and keeps the global directory sandboxed", async () => { root = await mkdtemp(join(tmpdir(), "sandbox-output-test-")); config.CONVERSATION_WORKSPACE_ROOT = root; process.env.XDG_DATA_HOME = join(root, "native"); const output = join(process.env.XDG_DATA_HOME, "opencode", "tool-output"); await mkdir(output, { recursive: true }); const own = join(output, "tool_own"), other = join(output, "tool_other"); const content = JSON.stringify({ evidence_id: "fixture", readings: "完整结果".repeat(1000) }); await writeFile(own, content); await writeFile(other, "other session"); const directory = await createConversationWorkspace(root); setRuntimeSessionContext({ sessionId: session, clientSessionId: session, actorKey: "owner", projectKey: "project", traceId: "test" }); const metadata = { truncated: true, outputPath: own }; const app = express(); app.use(express.json()); registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: { ensureSessionWorkspace: async () => directory, approveSandboxShell: async () => {}, messages: async () => [{ info: { id: "native", role: "assistant" }, parts: [{ type: "tool", state: { status: "completed", metadata }, }] }] as never, } }); server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server!.once("listening", resolve)); const post = async (command: string) => { const response = await fetch(`http://127.0.0.1:${(server!.address() as { port: number }).port}/internal/tools/sandbox-shell`, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "TEST_ONLY" }, body: JSON.stringify({ session_id: session, call_id: "fixture", message_id: "fixture", command }), }); return { status: response.status, data: await response.json() as Record }; }; const staged = await post(`cat '${own}'`); expect(staged.status).toBe(200); expect(staged.data.outcome).toBe("requires_session_path"); const file = staged.data.files[0]; expect(await readFile(join(directory, file.relative_path), "utf8")).toBe(content); const result = await post(`python3 -c 'import hashlib; print(hashlib.sha256(open("${file.shell_path}","rb").read()).hexdigest())'`); expect(result.data.exitCode).toBe(0); expect(result.data.stdout.trim()).toBe(file.sha256); const forbidden = await post(`cat '${other}'`); expect(forbidden.data.exitCode).not.toBe(0); expect(forbidden.data.stderr).toContain("Permission denied"); const link = join(output, "tool_link"); await symlink(other, link); metadata.outputPath = link; expect((await post(`cat '${link}'`)).status).toBe(503); });