Reuse OpenCode 2 HTTP/stdio clients with per-session read-only grants, native approval and sanitized owner-scoped MCP status. Narrow the environment inherited by trusted local MCP processes. Validated in an isolated checkout with bun run check and 20 focused tests.
238 lines
11 KiB
TypeScript
238 lines
11 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
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 {
|
|
OpencodeRuntimeAdapter,
|
|
resolveEmbeddedOpencodePort,
|
|
} from "../../src/runtime/opencode.js";
|
|
|
|
const createRuntimeAdapter = (
|
|
messages: unknown[],
|
|
calls: {
|
|
reverted: string[];
|
|
removed: string[];
|
|
} = { reverted: [], removed: [] },
|
|
) =>
|
|
Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
|
messages: async () => messages,
|
|
revertMessage: async (_sessionId: string, messageId: string) => {
|
|
calls.reverted.push(messageId);
|
|
},
|
|
removeMessage: async (_sessionId: string, messageId: string) => {
|
|
calls.removed.push(messageId);
|
|
},
|
|
}) as OpencodeRuntimeAdapter;
|
|
|
|
describe("OpencodeRuntimeAdapter.revertToUserMessage", () => {
|
|
it("skips reverting the first user message when the runtime session is empty", async () => {
|
|
const calls = { reverted: [] as string[], removed: [] as string[] };
|
|
const runtime = createRuntimeAdapter([], calls);
|
|
|
|
await runtime.revertToUserMessage("session-1", { userOrdinal: 1 });
|
|
|
|
expect(calls).toEqual({ reverted: [], removed: [] });
|
|
});
|
|
|
|
it("keeps ordinal mismatches visible when runtime messages exist", async () => {
|
|
const runtime = createRuntimeAdapter([
|
|
{ info: { id: "user-1", role: "user" } },
|
|
{ info: { id: "assistant-1", role: "assistant" } },
|
|
]);
|
|
|
|
await expect(
|
|
runtime.revertToUserMessage("session-1", { userOrdinal: 2 }),
|
|
).rejects.toThrow("target user message not found to revert");
|
|
});
|
|
|
|
it("reverts and removes messages from the target user message onward", async () => {
|
|
const calls = { reverted: [] as string[], removed: [] as string[] };
|
|
const runtime = createRuntimeAdapter(
|
|
[
|
|
{ info: { id: "user-1", role: "user" } },
|
|
{ info: { id: "assistant-1", role: "assistant" } },
|
|
{ info: { id: "user-2", role: "user" } },
|
|
{ info: { id: "assistant-2", role: "assistant" } },
|
|
],
|
|
calls,
|
|
);
|
|
|
|
await runtime.revertToUserMessage("session-1", { userOrdinal: 2 });
|
|
|
|
expect(calls).toEqual({
|
|
reverted: ["user-2"],
|
|
removed: ["user-2"],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("OpencodeRuntimeAdapter.ensureClient", () => {
|
|
it("retries bootstrap after a failed startup attempt", async () => {
|
|
let attempts = 0;
|
|
const client = {
|
|
global: { health: async () => ({ data: { healthy: true } }) },
|
|
} as unknown as OpencodeClient;
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
|
clientPromise: null,
|
|
closeServer: null,
|
|
bootstrapClient: async () => {
|
|
attempts += 1;
|
|
if (attempts === 1) {
|
|
throw new Error("startup failed");
|
|
}
|
|
return client;
|
|
},
|
|
}) as OpencodeRuntimeAdapter;
|
|
|
|
await expect(runtime.ensureClient()).rejects.toThrow("startup failed");
|
|
await expect(runtime.ensureClient()).resolves.toBe(client);
|
|
expect(attempts).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("OpencodeRuntimeAdapter.resolveAvailableModel", () => {
|
|
it("selects the first preferred model actually exposed by OpenCode", async () => {
|
|
const client = {
|
|
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), {
|
|
ensureClient: async () => client,
|
|
}) as OpencodeRuntimeAdapter;
|
|
|
|
await expect(runtime.resolveAvailableModel([
|
|
"deepseek/deepseek-flash",
|
|
"deepseek/deepseek-v4-pro",
|
|
])).resolves.toBe("deepseek/deepseek-v4-pro");
|
|
});
|
|
});
|
|
|
|
describe("resolveEmbeddedOpencodePort", () => {
|
|
it("falls back to a dynamic port when the configured port is occupied", async () => {
|
|
const server = createServer();
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
server.close();
|
|
throw new Error("expected a TCP server address");
|
|
}
|
|
|
|
try {
|
|
await expect(
|
|
resolveEmbeddedOpencodePort("127.0.0.1", address.port),
|
|
).resolves.toBe(0);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("OpencodeRuntimeAdapter.warmup", () => {
|
|
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 = {
|
|
server: { info: async () => { calls.push("health"); return { version: "2.0.24" }; } },
|
|
session: {
|
|
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 (input: { method: string }) => {
|
|
if (input.method === "mcp") { calls.push("mcp.list"); return { output: { servers: [], configuration_errors: [] } }; }
|
|
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), { 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", "mcp.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(); }
|
|
});
|
|
});
|
|
|
|
describe("conversation workspace binding", () => {
|
|
it("reads MCP status without migrating a legacy session or creating a workspace", async () => {
|
|
let location: unknown;
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
|
ensureClient: async () => ({
|
|
session: { get: async () => ({ location: { directory: process.cwd() } }), move: async () => { throw new Error("GET must not move sessions"); } },
|
|
rpc: { call: async (input: { location: unknown }) => { location = input.location; return { output: { servers: [], configuration_errors: [] } }; } },
|
|
}),
|
|
ensureSessionWorkspace: async () => { throw new Error("GET must not create workspaces"); },
|
|
}) as OpencodeRuntimeAdapter;
|
|
expect(await runtime.mcpStatus("legacy-session")).toEqual({ servers: [], configuration_errors: [] });
|
|
expect(location).toEqual({ directory: process.cwd() });
|
|
});
|
|
|
|
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 }); }
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|