Auto-approve sandbox Shell only for host-bound task execution, stage native results within their owning session, and preserve active lease state on response stream loss. The repeated failures came from interactive approval in unattended runs and transport cleanup invalidating execution state; focused regressions cover both boundaries.
109 lines
6.6 KiB
TypeScript
109 lines
6.6 KiB
TypeScript
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<void>((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<void>((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<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`);
|
|
});
|
|
});
|
|
|
|
|
|
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<void>((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<string, any> };
|
|
};
|
|
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);
|
|
});
|