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.
58 lines
3.7 KiB
TypeScript
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`);
|
|
});
|
|
});
|