| 1 | // Capability grants: CODEWHALE_CU_GRANT narrows the advertised and callable |
| 2 | // surface for the whole server process, fixed at launch; the daemon enforces |
| 3 | // the same set independently (covered in session-lifecycle.test.mjs). |
| 4 | import { test } from "node:test"; |
| 5 | import assert from "node:assert/strict"; |
| 6 | import fs from "node:fs"; |
| 7 | import os from "node:os"; |
| 8 | import path from "node:path"; |
| 9 | import url from "node:url"; |
| 10 | import { spawn } from "node:child_process"; |
| 11 | |
| 12 | const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), ".."); |
| 13 | |
| 14 | async function boot(t, grant) { |
| 15 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-grant-")); |
| 16 | const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-grant-rec-")); |
| 17 | const child = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], { |
| 18 | env: { ...process.env, CODEWHALE_CU_STATE_DIR: stateDir, CODEWHALE_CU_RECORDINGS_DIR: recDir, CODEWHALE_CU_APP: "off", CODEWHALE_CU_GRANT: grant }, |
| 19 | stdio: ["pipe", "pipe", "pipe"], |
| 20 | }); |
| 21 | t.after(() => { try { child.stdin.end(); } catch {} child.kill("SIGTERM"); fs.rmSync(stateDir, { recursive: true, force: true }); fs.rmSync(recDir, { recursive: true, force: true }); }); |
| 22 | let buf = ""; |
| 23 | const pending = new Map(); |
| 24 | let nextId = 1; |
| 25 | child.stdout.on("data", (c) => { |
| 26 | buf += c.toString(); |
| 27 | let i; |
| 28 | while ((i = buf.indexOf("\n")) !== -1) { |
| 29 | const line = buf.slice(0, i).trim(); |
| 30 | buf = buf.slice(i + 1); |
| 31 | if (!line) continue; |
| 32 | const msg = JSON.parse(line); |
| 33 | if (msg.id != null && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); } |
| 34 | } |
| 35 | }); |
| 36 | const rpc = (method, params) => { |
| 37 | const id = nextId++; |
| 38 | return new Promise((resolve, reject) => { |
| 39 | const timer = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 20_000); |
| 40 | pending.set(id, (msg) => { clearTimeout(timer); resolve(msg); }); |
| 41 | child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); |
| 42 | }); |
| 43 | }; |
| 44 | const tool = async (name, args = {}) => JSON.parse((await rpc("tools/call", { name, arguments: args })).result.content[0].text); |
| 45 | return { rpc, tool }; |
| 46 | } |
| 47 | |
| 48 | test("read-only grant: advertised and callable surface is the read-only set (plus parents with a read-only action)", async (t) => { |
| 49 | const s = await boot(t, "read-only"); |
| 50 | const names = (await s.rpc("tools/list", {})).result.tools.map((x) => x.name); |
| 51 | for (const kept of ["wait", "list_apps", "get_app_state", "request_access", "stop_computer_control", "computer", "browser", "trajectory"]) { |
| 52 | assert.ok(names.includes(kept), `${kept} must stay advertised under read-only`); |
| 53 | } |
| 54 | for (const gone of ["click", "pointer", "type", "key", "set_value", "kill_app", "set_window_frame", "open_application", "run_actions", "invoke_menu"]) { |
| 55 | assert.ok(!names.includes(gone), `${gone} must not be advertised under read-only`); |
| 56 | } |
| 57 | assert.equal((await s.tool("wait", { seconds: 0.01 })).ok, true); |
| 58 | assert.equal((await s.tool("computer", { action: "list" })).ok, true); |
| 59 | assert.equal((await s.tool("trajectory", { action: "status" })).ok, true); |
| 60 | assert.equal((await s.tool("browser", { action: "status" })).running, false); |
| 61 | assert.equal((await s.tool("click", { target: { type: "coordinate", x: 5, y: 5 } })).error?.code, "not_granted"); |
| 62 | assert.equal((await s.tool("left_click", { target: { type: "coordinate", x: 5, y: 5 } })).error?.code, "not_granted", "the alias takes the same gate"); |
| 63 | assert.equal((await s.tool("browser", { action: "start" })).error?.code, "not_granted", "an ungranted action on a granted parent is refused"); |
| 64 | const probe = await s.tool("request_access", {}); |
| 65 | assert.equal(probe.grant?.mode, "narrowed"); |
| 66 | assert.ok(probe.grant.tools.includes("wait")); |
| 67 | }); |
| 68 | |
| 69 | test("named grant admits exactly the named wires and their parents", async (t) => { |
| 70 | const s = await boot(t, "wait,computer_list"); |
| 71 | const names = (await s.rpc("tools/list", {})).result.tools.map((x) => x.name); |
| 72 | assert.ok(names.includes("wait") && names.includes("computer") && names.includes("stop_computer_control")); |
| 73 | assert.ok(!names.includes("list_apps") && !names.includes("click") && !names.includes("kill_app") && !names.includes("trajectory") && !names.includes("browser") && !names.includes("request_access")); |
| 74 | assert.equal((await s.tool("computer", { action: "list" })).ok, true); |
| 75 | assert.equal((await s.tool("computer_list", {})).ok, true, "the wire name is callable directly"); |
| 76 | assert.equal((await s.tool("list_apps", {})).error?.code, "not_granted"); |
| 77 | assert.equal((await s.tool("computer", { action: "switch", id: "local" })).error?.code, "not_granted", "computer_switch is not granted"); |
| 78 | assert.equal((await s.tool("stop_computer_control", {})).ok, true, "the safety valve always works"); |
| 79 | }); |
| 80 |