| 1 | // app_script: the programmatic interface into apps with a scripting |
| 2 | // dictionary. Local-computer only — a remote channel must never become a |
| 3 | // shell, so ssh/hdc computers refuse before dispatch and the remote agent |
| 4 | // refuses again at its own handler boundary. |
| 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 | import { handle } from "../src/app-handler.mjs"; |
| 13 | |
| 14 | const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), ".."); |
| 15 | |
| 16 | async function boot(t, env = {}) { |
| 17 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-script-")); |
| 18 | const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-script-rec-")); |
| 19 | const child = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], { |
| 20 | env: { ...process.env, CODEWHALE_CU_STATE_DIR: stateDir, CODEWHALE_CU_RECORDINGS_DIR: recDir, CODEWHALE_CU_APP: "off", ...env }, |
| 21 | stdio: ["pipe", "pipe", "pipe"], |
| 22 | }); |
| 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 }) + "\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("tools/list advertises app_script with a required script", async (t) => { |
| 51 | const s = await boot(t); |
| 52 | const tools = (await s.rpc("tools/list", {})).result.tools; |
| 53 | const def = tools.find((x) => x.name === "app_script"); |
| 54 | assert.ok(def, "app_script must be advertised"); |
| 55 | assert.deepEqual(def.inputSchema.required, ["script"]); |
| 56 | assert.equal(def.annotations?.readOnlyHint, false, "scripting mutates apps; hosts must not treat it as read-only"); |
| 57 | }); |
| 58 | |
| 59 | test("app_script runs AppleScript and JXA on the local computer", { skip: process.platform !== "darwin" }, async (t) => { |
| 60 | const s = await boot(t); |
| 61 | const as = await s.tool("app_script", { script: 'return "whole computer"' }); |
| 62 | assert.equal(as.ok, true); |
| 63 | assert.equal(as.result, "whole computer"); |
| 64 | const jxa = await s.tool("app_script", { script: '"ok".toUpperCase()', language: "javascript" }); |
| 65 | assert.equal(jxa.ok, true); |
| 66 | assert.equal(jxa.result, "OK"); |
| 67 | assert.equal(jxa.language, "javascript"); |
| 68 | }); |
| 69 | |
| 70 | test("app_script failures are typed, never opaque", { skip: process.platform !== "darwin" }, async (t) => { |
| 71 | const s = await boot(t); |
| 72 | const bad = await s.tool("app_script", { script: "this is not applescript at all" }); |
| 73 | assert.equal(bad.ok, false); |
| 74 | assert.equal(bad.error.code, "script_error"); |
| 75 | assert.match(bad.error.message, /syntax error|expected/i); |
| 76 | for (const [args, match] of [ |
| 77 | [{ script: " " }, /non-empty script/], |
| 78 | [{ script: "return 1", language: "perl" }, /language/], |
| 79 | [{ script: "return 1", timeout: 0 }, /timeout/], |
| 80 | [{ script: "return 1", timeout: 500 }, /timeout/], |
| 81 | ]) { |
| 82 | const r = await s.tool("app_script", args); |
| 83 | assert.equal(r.error?.code, "bad_args", JSON.stringify(args)); |
| 84 | assert.match(r.error.message, match); |
| 85 | } |
| 86 | }); |
| 87 | |
| 88 | test("app_script is refused on remote computers before any dispatch", async (t) => { |
| 89 | const s = await boot(t); |
| 90 | const reg = await s.tool("computer", { action: "register", id: "faraway", transport: "ssh", host: "192.0.2.1", installAgent: false }); |
| 91 | assert.equal(reg.ok, true); |
| 92 | const r = await s.tool("app_script", { script: "return 1", computer: "faraway" }); |
| 93 | assert.equal(r.ok, false); |
| 94 | assert.equal(r.error.code, "unsupported_on_transport"); |
| 95 | }); |
| 96 | |
| 97 | test("the remote agent refuses app_script at its own boundary", async () => { |
| 98 | const r = await handle({ tool: "app_script", args: { script: "return 1" } }, { computerId: "remote", sessionId: "ssh-serve" }); |
| 99 | assert.equal(r.ok, false); |
| 100 | assert.equal(r.error.code, "unsupported_on_transport"); |
| 101 | }); |
| 102 | |
| 103 | test("the local handler runs app_script through the normal session machinery", { skip: process.platform !== "darwin" }, async () => { |
| 104 | const r = await handle({ tool: "app_script", args: { script: 'return "via handler"' } }, { computerId: "local", sessionId: "test-script" }); |
| 105 | assert.equal(r.ok, true); |
| 106 | assert.equal(r.tool, "app_script"); |
| 107 | assert.equal(r.data.result, "via handler"); |
| 108 | }); |
| 109 | |
| 110 | test("a read-only grant never advertises or calls app_script", async (t) => { |
| 111 | const s = await boot(t, { CODEWHALE_CU_GRANT: "read-only" }); |
| 112 | const names = (await s.rpc("tools/list", {})).result.tools.map((x) => x.name); |
| 113 | assert.ok(!names.includes("app_script")); |
| 114 | assert.equal((await s.tool("app_script", { script: "return 1" })).error?.code, "not_granted"); |
| 115 | }); |
| 116 | |
| 117 | test("a named grant admits app_script exactly", { skip: process.platform !== "darwin" }, async (t) => { |
| 118 | const s = await boot(t, { CODEWHALE_CU_GRANT: "app_script" }); |
| 119 | const names = (await s.rpc("tools/list", {})).result.tools.map((x) => x.name); |
| 120 | assert.ok(names.includes("app_script")); |
| 121 | assert.equal((await s.tool("app_script", { script: "return 42" })).result, "42"); |
| 122 | }); |
| 123 | |
| 124 | test("the kill switch stops scripting too", async (t) => { |
| 125 | const s = await boot(t); |
| 126 | assert.equal((await s.tool("stop_computer_control", {})).ok, true); |
| 127 | const r = await s.tool("app_script", { script: "return 1" }); |
| 128 | assert.equal(r.ok, false); |
| 129 | assert.equal(r.error.code, "control_stopped"); |
| 130 | }); |
| 131 |