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.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { expect, it } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createServer } from "node:net";
|
||||
|
||||
const availablePort = () => new Promise<number>((done, reject) => {
|
||||
const server = createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const port = (server.address() as { port: number }).port;
|
||||
server.close((error) => error ? reject(error) : done(port));
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the isolated connection path to OpenCode before the Agent writes it", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "query-launcher-test-"));
|
||||
try {
|
||||
const ports = await Promise.all([availablePort(), availablePort()]);
|
||||
// Execute the real launcher up to its first dependency. No OpenCode, Agent,
|
||||
// provider or business service starts; the dependency records its inputs.
|
||||
const script = `import { mock } from "bun:test";
|
||||
mock.module(${JSON.stringify(resolve("src/runtime/opencode2-server.ts"))}, () => ({ startOpencode2: async (options) => {
|
||||
console.log("LAUNCHER_ENV=" + JSON.stringify({ connection: options.env.TJWATER_AGENT_MCP_CONNECTION_PATH }));
|
||||
return { password: "TEST_ONLY", exited: Promise.resolve(0), close() {}, client: { server: { async info() { throw new Error("STOP_BEFORE_AGENT"); } } } };
|
||||
} }));
|
||||
await import(${JSON.stringify(resolve("scripts/start-isolated-integration.ts"))});`;
|
||||
const child = Bun.spawn([process.execPath, "-e", script], { cwd: process.cwd(),
|
||||
env: { ...process.env, INTEGRATION_DATA_DIR: root, INTEGRATION_AGENT_PORT: String(ports[0]), INTEGRATION_OPENCODE_PORT: String(ports[1]),
|
||||
TJWATER_AGENT_MCP_CONNECTION_PATH: "/unrelated/connection.json", AGENT_INTERNAL_TOKEN: "TEST_ONLY" }, stdout: "pipe", stderr: "pipe" });
|
||||
const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
||||
expect(code).toBe(1);
|
||||
expect(err).toContain("STOP_BEFORE_AGENT");
|
||||
const record = out.split("\n").find((line) => line.startsWith("LAUNCHER_ENV="));
|
||||
expect(record).toBeDefined();
|
||||
expect(JSON.parse(record!.slice("LAUNCHER_ENV=".length)).connection).toBe(join(root, "mcp-query", "connection.json"));
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -26,7 +26,7 @@ describe("MCP configuration and grants", () => {
|
||||
]) expect(() => normalizeMcpConfig({ servers })).toThrow();
|
||||
});
|
||||
|
||||
it("requires an exact service allow rule and keeps later denials and MCP confirmations effective", () => {
|
||||
it("requires an exact service allow rule, keeps later denials and applies round mode to MCP requests", () => {
|
||||
expect(mcpReadonlyTools({ permissions: [{ action: "mcp_*", resource: "*", effect: "allow" }] })).toEqual([]);
|
||||
const permissions = [
|
||||
{ action: "mcp_*", resource: "*", effect: "ask" },
|
||||
@@ -36,9 +36,10 @@ describe("MCP configuration and grants", () => {
|
||||
];
|
||||
expect(mcpReadonlyTools({ permissions })).toEqual(["mcp_fixture_read"]);
|
||||
expect(mcpReadonlyTools({ permissions, agents: { instruction: { permissions: [{ action: "mcp_fixture_read", resource: "*", effect: "deny" }] } } })).toEqual([]);
|
||||
expect(canAutomaticallyApprove("always", "mcp_fixture_write")).toBe(false);
|
||||
expect(canAutomaticallyApprove("always", "opencode_read_mcp_resource")).toBe(false);
|
||||
expect(canAutomaticallyApprove("always", "query_project_data")).toBe(true);
|
||||
for (const action of ["mcp_fixture_write", "opencode_read_mcp_resource", "mcp_tjwater_query_scada_readings"]) {
|
||||
expect(canAutomaticallyApprove("request", action)).toBe(false);
|
||||
expect(canAutomaticallyApprove("always", action)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("shadows inherited credentials and reserved overrides for a trusted stdio process", () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { LOCAL_QUERY_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
|
||||
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 { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
import { bindScopedConversationWorkspace, createConversationWorkspace, migrateConversationWorkspace, sessionWorkspaceDirectory } from "../../src/runtime/conversationWorkspace.js";
|
||||
|
||||
import { config } from "../../src/config.js";
|
||||
import { modelCheckTargets } from "../../src/runtime/model-check.js";
|
||||
@@ -29,6 +31,44 @@ const createRuntimeAdapter = (
|
||||
},
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
|
||||
describe("OpencodeRuntimeAdapter.removeSession", () => {
|
||||
it("removes its native session and workspace, rejects a foreign binding, and preserves files on failure", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "tjwater-session-cleanup-"));
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
const scope = { actorKey: "actor-a", projectKey: "project-a" };
|
||||
const id = "cleanup-session";
|
||||
const directory = await bindScopedConversationWorkspace(await createConversationWorkspace(root), root, { ...scope, sessionId: id });
|
||||
const foreign = await bindScopedConversationWorkspace(await createConversationWorkspace(root), root, { ...scope, sessionId: "foreign-session" });
|
||||
const calls: string[] = [];
|
||||
let binding = directory;
|
||||
let removed = false;
|
||||
let failRemoval = true;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => ({ session: {
|
||||
get: async () => { if (removed) throw { _tag: "SessionNotFoundError" }; return { id, location: { directory: binding } }; },
|
||||
interrupt: async () => { calls.push("interrupt"); },
|
||||
active: async () => ({}),
|
||||
remove: async () => { if (failRemoval) throw new Error("native removal failed"); removed = true; calls.push("remove"); },
|
||||
} }),
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
try {
|
||||
binding = foreign;
|
||||
await expect(runtime.removeSession(id)).rejects.toThrow("another session");
|
||||
expect(calls).toEqual([]);
|
||||
binding = directory;
|
||||
await expect(runtime.removeSession(id, scope)).rejects.toThrow("native removal failed");
|
||||
expect((await lstat(directory)).isDirectory()).toBe(true);
|
||||
failRemoval = false;
|
||||
await runtime.removeSession(id, scope);
|
||||
await expect(lstat(directory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect((await lstat(foreign)).isDirectory()).toBe(true);
|
||||
expect(calls).toEqual(["interrupt", "interrupt", "remove"]);
|
||||
await runtime.removeSession(id, scope);
|
||||
} finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
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[] };
|
||||
@@ -95,6 +135,47 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter.generateText", () => {
|
||||
it("uses scoped one-shot generation and cleans its temporary context on success and failure", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "prompt-suggestion-runtime-"));
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
const controller = new AbortController();
|
||||
const calls: unknown[] = [];
|
||||
let fail = false;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => ({
|
||||
rpc: { call: async () => { calls.push("catalog ready"); } },
|
||||
session: {
|
||||
create: async (input: unknown) => { calls.push(input); return { id: "temporary-generator" }; },
|
||||
generate: async (input: unknown, options: { signal?: AbortSignal }) => {
|
||||
calls.push({ input, signal: options.signal });
|
||||
if (fail) throw new Error("inference failed");
|
||||
return { text: "generated suggestions" };
|
||||
},
|
||||
remove: async (input: unknown) => { calls.push(input); },
|
||||
prompt: () => { throw new Error("must not dispatch a chat turn"); },
|
||||
},
|
||||
}),
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
try {
|
||||
expect(await runtime.generateText("recommend questions", { providerID: "test", modelID: "fast" }, controller.signal)).toBe("generated suggestions");
|
||||
expect(calls[0]).toMatchObject({ model: { providerID: "test", id: "fast" } });
|
||||
expect(calls[1]).toBe("catalog ready");
|
||||
expect(calls[2]).toEqual({ input: { sessionID: "temporary-generator", prompt: "recommend questions" }, signal: controller.signal });
|
||||
expect(calls[3]).toEqual({ sessionID: "temporary-generator" });
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
fail = true;
|
||||
await expect(runtime.generateText("recommend questions")).rejects.toThrow("inference failed");
|
||||
expect(calls.at(-1)).toEqual({ sessionID: "temporary-generator" });
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
} finally {
|
||||
config.CONVERSATION_WORKSPACE_ROOT = previousRoot;
|
||||
await rm(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter.resolveAvailableModel", () => {
|
||||
it("selects the first preferred model actually exposed by OpenCode", async () => {
|
||||
const client = {
|
||||
@@ -141,12 +222,14 @@ describe("resolveEmbeddedOpencodePort", () => {
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
const toolIds = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "present_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[] = [], invalidModel?: string) {
|
||||
const toolIds = [...LOCAL_QUERY_TOOL_IDS, "websearch", "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
|
||||
async function createWarmupRuntime(missing: string[] = [], invalidModel?: string, delayedMcp = false,
|
||||
search: { providers?: readonly string[]; selection?: false | { provider: string }; unavailable?: boolean; extraTools?: readonly string[] } = {}) {
|
||||
const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-"));
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
const calls: string[] = [];
|
||||
let catalogReads = 0;
|
||||
const [providerID, id] = config.OPENCODE_MODEL.split("/");
|
||||
const client = {
|
||||
server: { info: async () => { calls.push("health"); return { version: "2.0.24" }; } },
|
||||
@@ -155,6 +238,16 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
remove: async () => { calls.push("session.remove"); },
|
||||
},
|
||||
model: { list: async () => { calls.push("model.list"); return { data: [{ providerID, id, enabled: true, capabilities: { tools: true } }] }; } },
|
||||
websearch: { providers: async () => {
|
||||
calls.push("websearch.providers");
|
||||
if (search.unavailable) throw new Error("PRIVATE_URL_AND_SECRET");
|
||||
return { data: (search.providers ?? ["fixture-search"]).map((id) => ({ id, name: id })) };
|
||||
} },
|
||||
config: { get: async () => {
|
||||
calls.push("config.get");
|
||||
return [{ type: "directory", path: root }, { type: "document", info: { websearch: { provider: "unavailable-lower-precedence" } } },
|
||||
{ type: "document", info: { websearch: search.selection ?? { provider: "fixture-search" } } }];
|
||||
} },
|
||||
rpc: { call: async (input: { method: string }) => {
|
||||
if (input.method === "check_models") {
|
||||
calls.push("model.check");
|
||||
@@ -166,7 +259,8 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
}
|
||||
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 })) };
|
||||
catalogReads++;
|
||||
return { output: [...toolIds, ...(search.extraTools ?? [])].filter((id) => !missing.includes(id) && !(delayedMcp && catalogReads === 1 && id === LOCAL_QUERY_TOOL_IDS[0])).map((id) => ({ id, direct: id !== "websearch", sandboxed: id === "shell", workspaceRoot: root })) };
|
||||
} },
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
||||
@@ -175,13 +269,46 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
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.check", "model.list", "session.remove"]); } finally { await cleanup(); }
|
||||
expect(calls).toEqual(["health", "session.create", "tool.list", "websearch.providers", "config.get", "mcp.list", "model.check", "model.list", "session.remove"]); } finally { await cleanup(); }
|
||||
});
|
||||
it("rejects a missing native search tool or unusable provider configuration before readiness and cleans up", async () => {
|
||||
for (const [missing, search, message] of [
|
||||
[["websearch"], {}, "websearch tool is missing"],
|
||||
[[], { providers: [] }, "no registered search provider"],
|
||||
[[], { selection: false }, "web search is disabled"],
|
||||
[[], { selection: { provider: "absent" } }, "configured search provider is unavailable"],
|
||||
[[], { unavailable: true }, "web search configuration unavailable"],
|
||||
[[], { extraTools: ["browser_click"] }, "retired tools are still registered"],
|
||||
[[], { extraTools: ["mcp_tjwater_web_search"] }, "retired tools are still registered"],
|
||||
] as const) {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime([...missing], undefined, false, search);
|
||||
try {
|
||||
await expect(runtime.warmup()).rejects.toThrow(message);
|
||||
expect(calls).not.toContain("model.check");
|
||||
expect(calls.at(-1)).toBe("session.remove");
|
||||
} finally { await cleanup(); }
|
||||
}
|
||||
});
|
||||
it("accepts random provider selection without sending a search query during startup", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime([], undefined, false, { selection: { provider: "random" } });
|
||||
try {
|
||||
await runtime.warmup();
|
||||
expect(calls).toContain("websearch.providers");
|
||||
} 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(); }
|
||||
});
|
||||
it("waits for native MCP catalog publication before reporting ready", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime([], undefined, true);
|
||||
try {
|
||||
await runtime.warmup();
|
||||
expect(calls.filter((call) => call === "tool.list")).toHaveLength(2);
|
||||
expect(calls.indexOf("model.check")).toBeGreaterThan(calls.lastIndexOf("tool.list"));
|
||||
} finally { await cleanup(); }
|
||||
});
|
||||
it("rejects an invalid non-default model before readiness and cleans up the session", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime([], "waternetwork/gpt-6.1-sol");
|
||||
try {
|
||||
@@ -193,6 +320,77 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
});
|
||||
|
||||
describe("conversation workspace binding", () => {
|
||||
it("recovers scoped bindings without moving files during MCP status reads", async () => {
|
||||
const base = await mkdtemp(join(tmpdir(), "scoped-binding-test-"));
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
config.CONVERSATION_WORKSPACE_ROOT = join(base, "data", "session-workspaces");
|
||||
const original = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
|
||||
await writeFile(join(original, "files", "result.txt"), "preserved");
|
||||
const owner = { actorKey: "actor-scope", projectKey: "project-scope", sessionId: "native-scope" };
|
||||
const expected = join(sessionWorkspaceDirectory(config.CONVERSATION_WORKSPACE_ROOT, owner), "workspace");
|
||||
let directory = original;
|
||||
let moves = 0;
|
||||
let reportedDirectory: string | undefined;
|
||||
const client = { session: {
|
||||
get: async () => ({ location: { directory } }),
|
||||
move: async (input: { directory: string }) => { if (++moves === 1) throw new Error("binding unavailable"); directory = input.directory; },
|
||||
} };
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => client,
|
||||
readMcpReport: async (_client: unknown, location: string) => { reportedDirectory = location; return { servers: [], configuration_errors: [] }; },
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
try {
|
||||
await expect(runtime.ensureSessionWorkspace(owner.sessionId, owner)).rejects.toThrow("binding unavailable");
|
||||
await runtime.mcpStatus(owner.sessionId, owner);
|
||||
expect(reportedDirectory).toBe(expected);
|
||||
expect(moves).toBe(1);
|
||||
expect(await readFile(join(expected, "files", "result.txt"), "utf8")).toBe("preserved");
|
||||
expect(await runtime.ensureSessionWorkspace(owner.sessionId, owner)).toBe(expected);
|
||||
expect(moves).toBe(2);
|
||||
} finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(base, { recursive: true, force: true }); }
|
||||
});
|
||||
it("moves legacy files into data and retries a failed native directory binding without losing results", async () => {
|
||||
const base = await mkdtemp(join(tmpdir(), "workspace-migration-test-"));
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
config.CONVERSATION_WORKSPACE_ROOT = join(base, "data", "session-workspaces");
|
||||
const original = await createConversationWorkspace(join(base, ".agent-workspaces"));
|
||||
await writeFile(join(original, "files", "result.json"), '{"value":7}');
|
||||
let directory = original;
|
||||
let moves = 0;
|
||||
const client = { session: {
|
||||
get: async () => ({ location: { directory } }),
|
||||
move: async (input: { directory: string }) => { if (++moves === 1) throw new Error("binding unavailable"); directory = input.directory; },
|
||||
} };
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
||||
try {
|
||||
await expect(runtime.ensureSessionWorkspace("legacy-session")).rejects.toThrow("binding unavailable");
|
||||
const [first, second] = await Promise.all([runtime.ensureSessionWorkspace("legacy-session"), runtime.ensureSessionWorkspace("legacy-session")]);
|
||||
expect(first).toBe(join(config.CONVERSATION_WORKSPACE_ROOT, basename(original)));
|
||||
expect(second).toBe(first);
|
||||
expect(moves).toBe(2);
|
||||
expect(await readFile(join(first, "files", "result.json"), "utf8")).toBe('{"value":7}');
|
||||
expect(await Bun.file(join(original, "files", "result.json")).exists()).toBe(false);
|
||||
const restored = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
||||
expect(await restored.ensureSessionWorkspace("legacy-session")).toBe(first);
|
||||
expect(moves).toBe(2);
|
||||
} finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(base, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("refuses a migration collision without overwriting either workspace", async () => {
|
||||
const base = await mkdtemp(join(tmpdir(), "workspace-collision-test-"));
|
||||
try {
|
||||
const root = join(base, "data", "session-workspaces");
|
||||
const original = await createConversationWorkspace(join(base, ".agent-workspaces"));
|
||||
const destination = join(root, basename(original));
|
||||
await mkdir(join(destination, "files"), { recursive: true });
|
||||
await writeFile(join(original, "files", "result.txt"), "original");
|
||||
await writeFile(join(destination, "files", "result.txt"), "destination");
|
||||
await expect(migrateConversationWorkspace(original, root)).rejects.toThrow("already exists");
|
||||
expect(await readFile(join(original, "files", "result.txt"), "utf8")).toBe("original");
|
||||
expect(await readFile(join(destination, "files", "result.txt"), "utf8")).toBe("destination");
|
||||
} finally { await rm(base, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
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), {
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
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 { createConversationWorkspace, resolveConversationWorkspace } from "../../src/runtime/conversationWorkspace.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 () => {
|
||||
root = await mkdtemp(join(tmpdir(), "tjwater-sandbox-test-"));
|
||||
base = await mkdtemp(join(tmpdir(), "tjwater-sandbox-test-"));
|
||||
root = join(base, "data", "session-workspaces");
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
workspace = await createConversationWorkspace(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(root, { recursive: true, force: true }); });
|
||||
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);
|
||||
@@ -38,6 +43,7 @@ describe("conversation OS sandbox", () => {
|
||||
`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",
|
||||
@@ -93,14 +99,17 @@ describe("conversation OS sandbox", () => {
|
||||
await expect(resolveConversationWorkspace(link, root)).rejects.toThrow("symbolic link");
|
||||
});
|
||||
|
||||
it("constrains native file tools and keeps arbitrary execution interactive", () => {
|
||||
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("always", action)).toBeFalse();
|
||||
expect(canAutomaticallyApprove("always", "web_search")).toBeTrue();
|
||||
for (const action of ["shell", "edit", "external_directory", "execute"]) {
|
||||
expect(canAutomaticallyApprove("request", action)).toBeFalse();
|
||||
expect(canAutomaticallyApprove("always", action)).toBeTrue();
|
||||
}
|
||||
expect(canAutomaticallyApprove("always", "websearch")).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { registerTaskAgentPolicy } from "../../.opencode/task-agent-policy.js";
|
||||
import { TASK_AGENT, TASK_READER_AGENT } from "../../src/runtime/task-agent-policy.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalToken === undefined) delete process.env.TJWATER_AGENT_INTERNAL_TOKEN;
|
||||
else process.env.TJWATER_AGENT_INTERNAL_TOKEN = originalToken;
|
||||
});
|
||||
|
||||
describe("investigation subagent plugin", () => {
|
||||
it("bounds parallel calls, rejects background/resume/other agents, and releases slots on failure", async () => {
|
||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN = "TEST_ONLY";
|
||||
let role = "coordinator";
|
||||
globalThis.fetch = (async () => Response.json({ role })) as unknown as typeof fetch;
|
||||
const pending: Array<() => void> = [];
|
||||
let entered = 0;
|
||||
const tool = { execute: async () => {
|
||||
entered++;
|
||||
await new Promise<void>((resolve) => pending.push(resolve));
|
||||
throw new Error("native failure");
|
||||
} };
|
||||
const hooks = new Map<string, (event: any) => Promise<void>>();
|
||||
await registerTaskAgentPolicy({
|
||||
tool: { transform: async (fn: any) => fn({ update: (_name: string, update: any) => update(tool) }) },
|
||||
permission: { hook: async (_name: string, fn: any) => { hooks.set("permission", fn); } },
|
||||
session: { hook: async (_name: string, fn: any) => { hooks.set("context", fn); } },
|
||||
} as never);
|
||||
const execute = tool.execute as unknown as (input: unknown, context: unknown) => Promise<unknown>;
|
||||
const call = (extra = {}) => execute({ agent: TASK_READER_AGENT, prompt: "check", description: "check", ...extra }, { sessionID: "parent", signal: new AbortController().signal });
|
||||
await expect(call({ background: true })).rejects.toThrow();
|
||||
await expect(call({ sessionID: "foreign-child" })).rejects.toThrow();
|
||||
await expect(call({ agent: "general" })).rejects.toThrow();
|
||||
const first = call().catch((error) => error);
|
||||
const second = call().catch((error) => error);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(entered).toBe(2);
|
||||
await expect(call()).rejects.toThrow("at most two");
|
||||
pending.splice(0).forEach((finish) => finish());
|
||||
await Promise.all([first, second]);
|
||||
const third = call().catch((error) => error);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(entered).toBe(3);
|
||||
pending.splice(0).forEach((finish) => finish());
|
||||
await third;
|
||||
role = "denied";
|
||||
await expect(call()).rejects.toThrow("only an active investigation");
|
||||
const permission = { sessionID: "ordinary", agent: "instruction", action: "subagent", resources: [TASK_READER_AGENT], effect: "allow" };
|
||||
await hooks.get("permission")!(permission);
|
||||
expect(permission.effect).toBe("deny");
|
||||
role = "coordinator";
|
||||
Object.assign(permission, { agent: TASK_AGENT, effect: "deny" });
|
||||
await hooks.get("permission")!(permission);
|
||||
expect(permission.effect).toBe("deny");
|
||||
role = "reader";
|
||||
const event = { sessionID: "child", agent: TASK_READER_AGENT, tools: {
|
||||
mcp_tjwater_get_task_context: {}, mcp_tjwater_query_scada_readings: {}, publish_task_report: {}, subagent: {}, execute: {}, shell: {},
|
||||
} };
|
||||
await hooks.get("context")!(event);
|
||||
expect(Object.keys(event.tools)).toEqual(["mcp_tjwater_get_task_context", "mcp_tjwater_query_scada_readings"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { PRODUCT_MCP_TOOL_IDS, PRODUCT_MCP_READ_TOOL_IDS, PRODUCT_MCP_ANALYSIS_TOOLS, queryMcpToolId } from "../../src/runtime/product-mcp-tools.js";
|
||||
import { allowedQueryTools } from "../../src/runtime/query-mcp-policy.js";
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { resolveTaskAgentScope, TASK_AGENT, TASK_READER_AGENT, type NativeTaskSession } from "../../src/runtime/task-agent-policy.js";
|
||||
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
|
||||
|
||||
const parentId = "task-policy-parent";
|
||||
afterEach(() => removeRuntimeSessionContext(parentId));
|
||||
const bind = (purpose: "ordinary_chat" | "task_execution" | "task_collaboration" = "task_execution") => {
|
||||
setRuntimeSessionContext({ sessionId: parentId, clientSessionId: parentId, actorKey: "a", projectKey: "p",
|
||||
projectId: "project-1", taskId: "task-1", traceId: "test", purpose });
|
||||
};
|
||||
|
||||
describe("task agent isolation", () => {
|
||||
it("keeps separate profiles and a finite reader tool allowlist", () => {
|
||||
const config = serviceOpencodeConfig({});
|
||||
expect(config.agents.instruction.permissions.at(-1)).toEqual({ action: "subagent", resource: "*", effect: "deny" });
|
||||
expect(config.agents[TASK_AGENT].permissions.at(-1)).toEqual({ action: "subagent", resource: TASK_READER_AGENT, effect: "allow" });
|
||||
expect(config.agents[TASK_READER_AGENT].permissions.map((rule) => rule.action)).toEqual([
|
||||
"*", ...PRODUCT_MCP_READ_TOOL_IDS,
|
||||
]);
|
||||
expect(PRODUCT_MCP_READ_TOOL_IDS).toHaveLength(25);
|
||||
expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(35);
|
||||
expect(PRODUCT_MCP_TOOL_IDS.some((name) => /publish|approve|execute|shell/.test(name))).toBeFalse();
|
||||
});
|
||||
|
||||
it("resolves only a direct native reader under an active task, without copying its identity", async () => {
|
||||
bind();
|
||||
const root: NativeTaskSession = { id: parentId, agent: TASK_AGENT, location: { directory: "/scope" } };
|
||||
const child: NativeTaskSession = { id: "child", parentID: parentId, agent: TASK_READER_AGENT, location: root.location };
|
||||
const get = async (id: string) => id === parentId ? root : child;
|
||||
expect((await resolveTaskAgentScope("child", get, () => true))?.owner.sessionId).toBe(parentId);
|
||||
expect(await resolveTaskAgentScope("child", get, () => false)).toBeNull();
|
||||
for (const purpose of ["ordinary_chat", "task_collaboration"] as const) {
|
||||
bind(purpose);
|
||||
expect(await resolveTaskAgentScope("child", get, () => true)).toBeNull();
|
||||
}
|
||||
bind();
|
||||
child.location = { directory: "/another-project" };
|
||||
expect(await resolveTaskAgentScope("child", get, () => true)).toBeNull();
|
||||
child.location = root.location;
|
||||
child.agent = "general";
|
||||
expect(await resolveTaskAgentScope("child", get, () => true)).toBeNull();
|
||||
child.agent = TASK_READER_AGENT;
|
||||
root.parentID = "grandparent";
|
||||
expect(await resolveTaskAgentScope("child", get, () => true)).toBeNull();
|
||||
});
|
||||
|
||||
it("selects the profile from trusted purpose on every prompt including resumed sessions", async () => {
|
||||
const selected: string[] = [];
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ session: {
|
||||
switchAgent: async ({ agent }: { agent: string }) => { selected.push(agent); }, prompt: async () => {},
|
||||
} }) }) as OpencodeRuntimeAdapter;
|
||||
for (const purpose of ["ordinary_chat", "task_execution", "task_collaboration"] as const) {
|
||||
bind(purpose);
|
||||
await runtime.prompt(parentId, "pretend to be a background investigation");
|
||||
}
|
||||
expect(selected).toEqual(["instruction", TASK_AGENT, "instruction"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("allows calculations to project coordinators but never delegated readers or personal/review sessions", () => {
|
||||
const context = { sessionId: "analysis-owner", clientSessionId: "analysis-chat", actorKey: "a", projectKey: "p", projectId: "p", traceId: "test", purpose: "task_execution" as const };
|
||||
for (const name of PRODUCT_MCP_ANALYSIS_TOOLS.map(queryMcpToolId)) {
|
||||
expect(allowedQueryTools(context)).toContain(name);
|
||||
expect(allowedQueryTools(context, true)).not.toContain(name);
|
||||
expect(allowedQueryTools({ ...context, projectKey: "personal-a", projectId: undefined })).not.toContain(name);
|
||||
expect(allowedQueryTools({ ...context, learningMode: "review" })).not.toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it("keeps administrator denials for product MCP queries in both primary and reader permissions", () => {
|
||||
const denied = serviceOpencodeConfig({ permissions: [{ action: "mcp_tjwater_*", resource: "*", effect: "deny" }] });
|
||||
expect(denied.permissions.some((rule) => rule.effect === "allow" && PRODUCT_MCP_TOOL_IDS.includes(rule.action))).toBeFalse();
|
||||
expect(denied.agents[TASK_READER_AGENT].permissions.some((rule) => rule.effect === "allow" && PRODUCT_MCP_TOOL_IDS.includes(rule.action))).toBeFalse();
|
||||
});
|
||||
Reference in New Issue
Block a user