Files
jiang 6a49d2209e feat(agent): unify MCP tools and adopt Responses delivery
Move project and host queries into native MCP with host-owned OAuth/context,
while retaining memory, skills, actions and publication plugins. Add nearby-model
queries and explicit chat/task/learning model defaults.

Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped
session ownership, pending input edits and investigation communication.

Disable local-provider discovery: Server /health allowed native vLLM to probe
/v1/models on port 8000. A native before/after regression prevents recurrence.

Validation: 170 focused Bun tests; service/plugin typechecks; native discovery,
provider-discovery and input-inbox smoke checks; no paid model inference.
2026-10-19 18:46:58 +08:00

116 lines
6.4 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { config } from "../../src/config.js";
import { bindScopedConversationWorkspace, createConversationWorkspace, resolveConversationWorkspace } from "../../src/runtime/conversationWorkspace.js";
import { executeSandboxCommand, probeLandlockSandbox } from "../../src/sandbox/landlockSandbox.js";
import { canAccessConversationPath } from "../../.opencode/session-sandbox.js";
import { canAutomaticallyApprove } from "../../src/routes/chatPermissionPolicy.js";
describe("conversation OS sandbox", () => {
let root: string;
let base: string;
let workspace: string;
let other: string;
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
beforeAll(async () => {
base = await mkdtemp(join(tmpdir(), "tjwater-sandbox-test-"));
root = join(base, "data", "session-workspaces");
config.CONVERSATION_WORKSPACE_ROOT = root;
workspace = await bindScopedConversationWorkspace(await createConversationWorkspace(root), root,
{ actorKey: "actor-sandbox", projectKey: "project-sandbox", sessionId: "sandbox-session" });
await mkdir(join(workspace, "..", "chat"), { mode: 0o700 });
await writeFile(join(workspace, "..", "chat", "messages.json"), "PRIVATE_CHAT_RECORD");
other = await createConversationWorkspace(root);
await writeFile(join(other, "files", "private.txt"), "other-session");
});
afterAll(async () => { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(base, { recursive: true, force: true }); });
it("supports Landlock and runs local file computation with a sanitized environment", async () => {
expect((await probeLandlockSandbox()).landlockAbi).toBeGreaterThanOrEqual(4);
const result = await executeSandboxCommand(workspace, "printf sandbox-ok > result.txt; cat result.txt; python3 -c 'import os; assert not any(k in os.environ for k in [\"AGENT_INTERNAL_TOKEN\",\"DEEPSEEK_API_KEY\",\"OPENCODE_CLIENT_PASSWORD\"]); print(os.getcwd())'", 10);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("sandbox-ok");
expect(result.stdout).toContain(join(workspace, "files"));
expect(await readFile(join(workspace, "files", "result.txt"), "utf8")).toBe("sandbox-ok");
});
it("denies other sessions, checkout files, symlink escape and config writes", async () => {
await symlink(join(other, "files", "private.txt"), join(workspace, "files", "escape"));
await symlink(join(other, "files", "not-created.txt"), join(workspace, "files", "broken-escape"));
for (const command of [
`cat '${other}/files/private.txt'`,
`cat '${process.cwd()}/src/config.ts'`,
"cat escape",
"cat ../../chat/messages.json",
"printf malicious > ../opencode.json",
"mkdir ../.opencode",
"printf denied > /usr/tjwater-sandbox-test",
]) {
const result = await executeSandboxCommand(workspace, command, 5);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("Permission denied");
}
});
it("blocks TCP, UDP, Unix sockets, process inspection and process-group escape", async () => {
for (const command of [
"python3 -c 'import socket; socket.socket(socket.AF_INET, socket.SOCK_STREAM)'",
"python3 -c 'import socket; socket.socket(socket.AF_INET, socket.SOCK_DGRAM)'",
"python3 -c 'import socket; socket.socket(socket.AF_UNIX)'",
"python3 -c 'import os; os.setsid()'",
`python3 -c 'import os; os.kill(${process.pid}, 0)'`,
]) {
const result = await executeSandboxCommand(workspace, command, 5);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("Operation not permitted");
}
});
it("kills children on timeout and client cancellation", async () => {
const timed = await executeSandboxCommand(workspace, "(sleep 2; printf leaked > timeout-leak.txt) & wait", 1);
expect(timed.outcome).toBe("timeout");
const controller = new AbortController();
const cancelled = executeSandboxCommand(workspace, "(sleep 2; printf leaked > cancel-leak.txt) & wait", 10, controller.signal);
setTimeout(() => controller.abort(), 100);
expect((await cancelled).outcome).toBe("cancelled");
await new Promise((resolve) => setTimeout(resolve, 2100));
expect(await Bun.file(join(workspace, "files", "timeout-leak.txt")).exists()).toBeFalse();
expect(await Bun.file(join(workspace, "files", "cancel-leak.txt")).exists()).toBeFalse();
});
it("reaps background children immediately when the foreground shell exits", async () => {
const result = await executeSandboxCommand(workspace, "(sleep 1; printf leaked > background-leak.txt) & printf foreground", 5);
expect(result.exitCode).toBe(0);
expect(result.outcome).toBe("completed");
expect(result.stdout).toBe("foreground");
await new Promise((resolve) => setTimeout(resolve, 1100));
expect(await Bun.file(join(workspace, "files", "background-leak.txt")).exists()).toBeFalse();
});
it("bounds output and rejects untrusted workspace paths", async () => {
const result = await executeSandboxCommand(workspace, "python3 -c 'print(\"x\" * 2000000)'", 5);
expect(result.outcome).toBe("output_limit");
expect(result.stdout).toBe("");
await expect(resolveConversationWorkspace(root, root)).rejects.toThrow("direct child");
const link = join(root, "conversation-link");
await symlink(workspace, link);
await expect(resolveConversationWorkspace(link, root)).rejects.toThrow("symbolic link");
});
it("constrains native file tools while respecting the selected approval mode", () => {
expect(canAccessConversationPath(workspace, "files/result.txt", true)).toBeTrue();
expect(canAccessConversationPath(workspace, "opencode.json", true)).toBeFalse();
expect(canAccessConversationPath(workspace, "files/escape")).toBeFalse();
expect(canAccessConversationPath(workspace, "files/broken-escape", true)).toBeFalse();
expect(canAccessConversationPath(workspace, "../other")).toBeFalse();
expect(canAccessConversationPath(workspace, "files/.env")).toBeFalse();
for (const action of ["shell", "edit", "external_directory", "execute"]) {
expect(canAutomaticallyApprove("request", action)).toBeFalse();
expect(canAutomaticallyApprove("always", action)).toBeTrue();
}
expect(canAutomaticallyApprove("always", "websearch")).toBeTrue();
});
});