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.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
import { type OpenCodeClient as OpencodeClient } from "@opencode/client";
|
||||
import { createServer } from "node:net";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { config } from "../../src/config.js";
|
||||
import {
|
||||
@@ -62,7 +65,7 @@ describe("OpencodeRuntimeAdapter.revertToUserMessage", () => {
|
||||
|
||||
expect(calls).toEqual({
|
||||
reverted: ["user-2"],
|
||||
removed: ["assistant-2", "user-2"],
|
||||
removed: ["user-2"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -94,19 +97,9 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
|
||||
describe("OpencodeRuntimeAdapter.resolveAvailableModel", () => {
|
||||
it("selects the first preferred model actually exposed by OpenCode", async () => {
|
||||
const client = {
|
||||
config: {
|
||||
providers: async () => ({
|
||||
data: {
|
||||
providers: [{
|
||||
id: "deepseek",
|
||||
models: {
|
||||
"deepseek-flash": {},
|
||||
"deepseek-v4-pro": {},
|
||||
},
|
||||
}],
|
||||
default: {},
|
||||
},
|
||||
}),
|
||||
rpc: { call: async () => ({ output: [] }) },
|
||||
model: {
|
||||
list: async () => ({ data: [{ providerID: "deepseek", id: "deepseek-v4-pro", enabled: true, capabilities: { tools: true } }] }),
|
||||
},
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
@@ -114,7 +107,7 @@ describe("OpencodeRuntimeAdapter.resolveAvailableModel", () => {
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
|
||||
await expect(runtime.resolveAvailableModel([
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"deepseek/deepseek-flash",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
])).resolves.toBe("deepseek/deepseek-v4-pro");
|
||||
});
|
||||
@@ -147,45 +140,81 @@ describe("resolveEmbeddedOpencodePort", () => {
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
it("initializes the project session and model tools before reporting ready", async () => {
|
||||
const toolIds = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"];
|
||||
async function createWarmupRuntime(missing: string[] = []) {
|
||||
const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-"));
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
const calls: string[] = [];
|
||||
const [providerID, id] = config.OPENCODE_MODEL.split("/");
|
||||
const client = {
|
||||
global: {
|
||||
health: async () => {
|
||||
calls.push("health");
|
||||
return { data: { healthy: true, version: "test" } };
|
||||
},
|
||||
},
|
||||
server: { info: async () => { calls.push("health"); return { version: "2.0.24" }; } },
|
||||
session: {
|
||||
create: async () => {
|
||||
calls.push("session.create");
|
||||
return { data: { id: "warmup-session" } };
|
||||
},
|
||||
delete: async ({ sessionID }: { sessionID: string }) => {
|
||||
calls.push(`session.delete:${sessionID}`);
|
||||
return { data: true };
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
list: async (model: { provider: string; model: string }) => {
|
||||
calls.push(`tool.list:${model.provider}/${model.model}`);
|
||||
return { data: [] };
|
||||
},
|
||||
create: async (input: { location: { directory: string } }) => { calls.push("session.create"); return { id: "warmup-session", location: input.location }; },
|
||||
remove: async () => { calls.push("session.remove"); },
|
||||
},
|
||||
model: { list: async () => { calls.push("model.list"); return { data: [{ providerID, id, enabled: true, capabilities: { tools: true } }] }; } },
|
||||
rpc: { call: async () => { calls.push("tool.list"); return { output: toolIds.filter((id) => !missing.includes(id)).map((id) => ({ id, direct: true, sandboxed: id === "shell", workspaceRoot: root })) }; } },
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
clientPromise: null,
|
||||
closeServer: null,
|
||||
ensureClient: async () => client,
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
||||
return { runtime, calls, cleanup: async () => { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true, force: true }); } };
|
||||
}
|
||||
it("verifies business tools and model before reporting ready and removes the warmup session", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime();
|
||||
try { await runtime.warmup();
|
||||
expect(calls).toEqual(["health", "session.create", "tool.list", "model.list", "session.remove"]); } finally { await cleanup(); }
|
||||
});
|
||||
it("fails readiness if a business tool was lost and still cleans up", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime(["render_scada_analysis"]);
|
||||
try { await expect(runtime.warmup()).rejects.toThrow("business tools missing: render_scada_analysis");
|
||||
expect(calls.at(-1)).toBe("session.remove"); } finally { await cleanup(); }
|
||||
});
|
||||
});
|
||||
|
||||
await runtime.warmup();
|
||||
describe("conversation workspace binding", () => {
|
||||
it("coalesces old-session migration and restores its persisted directory", async () => {
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
const root = await mkdtemp(join(tmpdir(), "runtime-workspace-test-"));
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
let directory = process.cwd();
|
||||
let moves = 0;
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ id: "old-session", location: { directory } }),
|
||||
move: async (input: { directory: string }) => { moves++; directory = input.directory; },
|
||||
},
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
||||
try {
|
||||
const [first, second] = await Promise.all([runtime.ensureSessionWorkspace("old-session"), runtime.ensureSessionWorkspace("old-session")]);
|
||||
expect(first).toBe(second);
|
||||
expect(first).not.toBe(process.cwd());
|
||||
expect(moves).toBe(1);
|
||||
const restored = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
||||
expect(await restored.ensureSessionWorkspace("old-session")).toBe(first);
|
||||
expect(moves).toBe(1);
|
||||
directory = "/tmp";
|
||||
await expect(restored.ensureSessionWorkspace("foreign-session")).rejects.toThrow("inside");
|
||||
} finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
"health",
|
||||
"session.create",
|
||||
`tool.list:${config.OPENCODE_MODEL}`,
|
||||
"session.delete:warmup-session",
|
||||
]);
|
||||
describe("explicit native Shell approval", () => {
|
||||
it("ties the pending decision to the full command and originating native call", async () => {
|
||||
const events = [
|
||||
{ type: "connected" },
|
||||
{ type: "permission.replied", data: { sessionID: "other", requestID: "perm-test", reply: "once" } },
|
||||
{ type: "permission.replied", data: { sessionID: "session-test", requestID: "perm-test", reply: "once" } },
|
||||
];
|
||||
let created: unknown;
|
||||
const client = {
|
||||
event: { subscribe: () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) },
|
||||
permission: { create: async (input: unknown) => { created = input; return { id: "perm-test", effect: "ask" }; } },
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
||||
await runtime.approveSandboxShell({ sessionId: "session-test", command: "printf hello", callId: "call-test", messageId: "message-test" }, new AbortController().signal);
|
||||
expect(created).toMatchObject({ action: "shell", resources: ["printf hello"], save: [], source: { type: "tool", id: "call-test", messageID: "message-test" } });
|
||||
events[2] = { type: "permission.replied", data: { sessionID: "session-test", requestID: "perm-test", reply: "reject" } };
|
||||
await expect(runtime.approveSandboxShell({ sessionId: "session-test", command: "printf hello", callId: "call-test", messageId: "message-test" }, new AbortController().signal)).rejects.toThrow("rejected");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { V2Event, FormInfo } from "@opencode/client";
|
||||
import { formAnswers, Opencode2EventNormalizer } from "../../src/runtime/opencode2-events.js";
|
||||
import { normalizeQuestionToolPayload } from "../../src/routes/chatStreamEvents.js";
|
||||
|
||||
const event = (type: string, data: unknown) => ({ type, data, created: 123 }) as V2Event;
|
||||
|
||||
describe("OpenCode 2 event contract", () => {
|
||||
it("ends the product stream on native execution completion", () => {
|
||||
const normalizer = new Opencode2EventNormalizer();
|
||||
expect(normalizer.normalize(event("session.execution.succeeded", { sessionID: "s" }))).toMatchObject([
|
||||
{ type: "session.status", properties: { sessionID: "s", status: { type: "idle" } } },
|
||||
{ type: "session.idle", properties: { sessionID: "s" } },
|
||||
]);
|
||||
});
|
||||
it("preserves tool identity, structured output and todo updates", () => {
|
||||
const normalizer = new Opencode2EventNormalizer();
|
||||
const data = { sessionID: "s", assistantMessageID: "m", id: "call-1" };
|
||||
normalizer.normalize(event("session.tool.input.started", { ...data, name: "todowrite" }));
|
||||
normalizer.normalize(event("session.tool.called", { ...data, input: { todos: [{ content: "check", status: "pending" }] } }));
|
||||
const result = normalizer.normalize(event("session.tool.success", { ...data, content: [{ type: "text", text: "[]" }] }));
|
||||
expect(result[0]).toMatchObject({ properties: { part: { callID: "call-1", tool: "todowrite", state: { status: "completed", output: "[]" } } } });
|
||||
expect(result[1]).toMatchObject({ type: "todo.updated", properties: { sessionID: "s", todos: [{ content: "check" }] } });
|
||||
});
|
||||
it("keeps permissions tied to the native session and call", () => {
|
||||
const normalizer = new Opencode2EventNormalizer();
|
||||
expect(normalizer.normalize(event("permission.asked", { id: "p", sessionID: "s", action: "shell", resources: ["echo ok"], source: { type: "tool", id: "call-2", messageID: "m" } }))[0]).toMatchObject({
|
||||
type: "permission.v2.asked", properties: { sessionID: "s", action: "shell", tool: { callID: "call-2", messageID: "m" } },
|
||||
});
|
||||
});
|
||||
it("waits for the native form ID instead of exposing a tool call ID as a question request", () => {
|
||||
const normalizer = new Opencode2EventNormalizer();
|
||||
const data = { sessionID: "s", assistantMessageID: "m", id: "call-q" };
|
||||
normalizer.normalize(event("session.tool.input.started", { ...data, name: "question" }));
|
||||
const events = normalizer.normalize(event("session.tool.called", { ...data, input: { questions: [{ header: "验证", question: "是否通过?", options: [] }] } }));
|
||||
const part = (events[0] as any).properties.part;
|
||||
expect(normalizeQuestionToolPayload(part, part.state.input, "client")).toBeUndefined();
|
||||
const forms = normalizer.normalize(event("form.created", { form: { id: "frm_test", sessionID: "s", title: "验证", fields: [{ key: "answer", type: "string", title: "是否通过?", options: [{ value: "ok", label: "通过" }] }] } }));
|
||||
expect(forms[0]).toMatchObject({ type: "question.v2.asked", properties: { id: "frm_test", questions: [{ question: "是否通过?" }] } });
|
||||
});
|
||||
it("maps displayed choices back to form values and preserves multiple selections", () => {
|
||||
const form = { id: "form-1", sessionID: "s", title: "选项", fields: [
|
||||
{ key: "mode", type: "string", options: [{ label: "快速", value: "fast" }] },
|
||||
{ key: "areas", type: "multiselect", options: [{ label: "东区", value: "east" }], custom: true },
|
||||
] } as FormInfo;
|
||||
expect(formAnswers(form, [["快速"], ["东区", "自选区域"]])).toEqual({ mode: "fast", areas: ["east", "自选区域"] });
|
||||
});
|
||||
});
|
||||
@@ -1,38 +1,23 @@
|
||||
import { describe, expect, it, test } from "bun:test";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
describe("internal OpenCode permissions", () => {
|
||||
it("keeps protected paths denied in every approval mode", async () => {
|
||||
const config = JSON.parse(await readFile("opencode.json", "utf8")) as {
|
||||
permission?: Record<string, string | Record<string, string>>;
|
||||
};
|
||||
const permission = config.permission ?? {};
|
||||
const bash = permission.bash as Record<string, string> | undefined;
|
||||
const edit = permission.edit as Record<string, string> | undefined;
|
||||
const read = permission.read as Record<string, string> | undefined;
|
||||
|
||||
expect(permission["*"]).toBe("ask");
|
||||
expect(permission.external_directory).toBe("deny");
|
||||
expect(permission.task).toBe("deny");
|
||||
expect(permission.question).toBe("allow");
|
||||
expect(permission.todowrite).toBe("allow");
|
||||
expect(read?.["*"]).toBe("allow");
|
||||
expect(read?.["data/**"]).toBe("deny");
|
||||
expect(read?.["**/logs/**"]).toBe("deny");
|
||||
expect(edit?.["*"]).toBe("ask");
|
||||
expect(edit?.["data/**"]).toBe("deny");
|
||||
expect(edit?.["**/logs/**"]).toBe("deny");
|
||||
expect(bash?.["*"]).toBe("ask");
|
||||
expect(bash?.["*.env*"]).toBe("deny");
|
||||
expect(bash?.["*data/*"]).toBe("deny");
|
||||
expect(bash?.["*logs/*"]).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode runtime config", () => {
|
||||
test("continues the agent loop after a question or permission is denied", async () => {
|
||||
const config = await Bun.file(new URL("../../opencode.json", import.meta.url)).json();
|
||||
|
||||
expect(config.experimental?.continue_loop_on_deny).toBe(true);
|
||||
describe("OpenCode 2 permissions", () => {
|
||||
it("keeps protected paths denied, shell/edit gated and subagents disabled", async () => {
|
||||
const config = JSON.parse(await readFile("opencode.json", "utf8"));
|
||||
const rules = config.permissions as { action: string; resource: string; effect: string }[];
|
||||
const effect = (action: string, resource: string) => rules.find((rule) => rule.action === action && rule.resource === resource)?.effect;
|
||||
expect(effect("*", "*")).toBe("ask");
|
||||
expect(effect("external_directory", "*")).toBe("deny");
|
||||
expect(effect("subagent", "*")).toBe("deny");
|
||||
expect(effect("question", "*")).toBe("allow");
|
||||
expect(effect("todowrite", "*")).toBe("allow");
|
||||
for (const action of ["read", "edit"]) {
|
||||
for (const path of [".env", "*.env", "data/**", "**/logs/**"]) expect(effect(action, path)).toBe("deny");
|
||||
}
|
||||
expect(effect("edit", "*")).toBe("ask");
|
||||
expect(effect("shell", "*")).toBe("ask");
|
||||
for (const pattern of ["*.env*", "*data/*", "*logs/*"]) expect(effect("shell", pattern)).toBe("deny");
|
||||
expect(config.permission).toBeUndefined();
|
||||
expect(config.experimental?.continue_loop_on_deny).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { 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 { 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 workspace: string;
|
||||
let other: string;
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "tjwater-sandbox-test-"));
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
workspace = await createConversationWorkspace(root);
|
||||
other = await createConversationWorkspace(root);
|
||||
await writeFile(join(other, "files", "private.txt"), "other-session");
|
||||
});
|
||||
afterAll(async () => { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { 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",
|
||||
"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 and keeps arbitrary execution interactive", () => {
|
||||
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("always", action)).toBeFalse();
|
||||
expect(canAutomaticallyApprove("always", "web_search")).toBeTrue();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user