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