| 1 | // Exercise the actual helper socket and MCP lifecycle with recording backends. |
| 2 | // Child-process cancellation must prevent delayed input, not just hide replies. |
| 3 | import { test, before, after } from "node:test"; |
| 4 | import assert from "node:assert/strict"; |
| 5 | import fs from "node:fs"; |
| 6 | import os from "node:os"; |
| 7 | import path from "node:path"; |
| 8 | import { fileURLToPath } from "node:url"; |
| 9 | import { spawn } from "node:child_process"; |
| 10 | |
| 11 | const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 12 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-session-")); |
| 13 | const log = path.join(dir, "calls.jsonl"); |
| 14 | const env = { |
| 15 | ...process.env, |
| 16 | CODEWHALE_CU_STATE_DIR: dir, |
| 17 | CODEWHALE_CU_APP_WARM: "off", |
| 18 | CODEWHALE_CU_TEST_BACKEND: path.join(ROOT, "tests/fixtures/session-backend.mjs"), |
| 19 | CU_SESSION_CALLS: log, |
| 20 | }; |
| 21 | delete env.CODEWHALE_CU_APP; |
| 22 | delete env.CODEWHALE_CU_APP_SOCKET; |
| 23 | delete env.CODEWHALE_CU_TEST_REMOTE; |
| 24 | process.env.CODEWHALE_CU_STATE_DIR = dir; |
| 25 | delete process.env.CODEWHALE_CU_APP_SOCKET; |
| 26 | const { appRequest, appSessionRequest, openAppSession, hello } = await import("../src/app-socket.mjs"); |
| 27 | const { appExec } = await import("../src/transport.mjs"); |
| 28 | let daemon; |
| 29 | let daemonErrors = ""; |
| 30 | const hosts = new Set(); |
| 31 | const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); |
| 32 | const calls = () => fs.existsSync(log) ? fs.readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse) : []; |
| 33 | async function until(check, timeoutMs = 3000) { |
| 34 | const end = Date.now() + timeoutMs; |
| 35 | do { |
| 36 | if (await check()) return; |
| 37 | await pause(20); |
| 38 | } while (Date.now() < end); |
| 39 | assert.fail(`Condition did not become true within ${timeoutMs}ms`); |
| 40 | } |
| 41 | |
| 42 | function mcp() { |
| 43 | const child = spawn(process.execPath, [path.join(ROOT, "mcp/server.mjs")], { env, stdio: ["pipe", "pipe", "pipe"] }); |
| 44 | hosts.add(child); |
| 45 | const replies = new Map(); |
| 46 | let buf = ""; |
| 47 | let id = 0; |
| 48 | child.stderr.on("data", () => {}); |
| 49 | child.stdout.on("data", (chunk) => { |
| 50 | buf += chunk.toString(); |
| 51 | let nl; |
| 52 | while ((nl = buf.indexOf("\n")) !== -1) { |
| 53 | const line = buf.slice(0, nl); buf = buf.slice(nl + 1); |
| 54 | const msg = JSON.parse(line); |
| 55 | replies.set(msg.id, msg); |
| 56 | } |
| 57 | }); |
| 58 | const send = (method, params, requestId) => child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...(requestId === undefined ? {} : { id: requestId }), method, params }) + "\n"); |
| 59 | const start = (name, args = {}) => { const requestId = ++id; send("tools/call", { name, arguments: args }, requestId); return requestId; }; |
| 60 | const response = async (requestId) => { |
| 61 | await until(() => replies.has(requestId)); |
| 62 | const msg = replies.get(requestId); |
| 63 | assert.ok(msg.result, JSON.stringify(msg.error)); |
| 64 | return JSON.parse(msg.result.content[0].text); |
| 65 | }; |
| 66 | return { child, replies, start, response, cancel: (requestId) => send("notifications/cancelled", { requestId }), tool: (name, args) => response(start(name, args)) }; |
| 67 | } |
| 68 | |
| 69 | async function closeHost(host) { |
| 70 | const exit = new Promise((resolve) => host.child.once("exit", resolve)); |
| 71 | host.child.stdin.end(); |
| 72 | await exit; |
| 73 | hosts.delete(host.child); |
| 74 | } |
| 75 | |
| 76 | before(async () => { |
| 77 | daemon = spawn(process.execPath, [path.join(ROOT, "app/daemon.mjs")], { env: {...env, CODEWHALE_CU_CONTROL_FD: "3"}, stdio: ["ignore", "ignore", "pipe", "overlapped"] }); |
| 78 | daemon.stderr.on("data", (data) => { daemonErrors += data; }); |
| 79 | await until(async () => !!(await hello({ timeoutMs: 100 })), 5000).catch((err) => { throw new Error(`${err.message}\n${daemonErrors}`); }); |
| 80 | }); |
| 81 | after(async () => { |
| 82 | for (const child of hosts) child.kill("SIGTERM"); |
| 83 | if (daemon?.exitCode === null) { |
| 84 | const exit = new Promise((resolve) => daemon.once("exit", resolve)); |
| 85 | daemon.kill("SIGTERM"); |
| 86 | await exit; |
| 87 | } |
| 88 | fs.rmSync(dir, { recursive: true, force: true }); |
| 89 | }); |
| 90 | |
| 91 | test("helper requires session identities while compatibility probes remain available", async () => { |
| 92 | assert.equal((await hello()).sessionProtocol, 2); |
| 93 | assert.equal((await appRequest({ tool: "platform" })).ok, true); |
| 94 | for (const sessionId of [undefined, "", "bad:session", "x".repeat(129)]) { |
| 95 | const reply = await appRequest({ tool: "type", args: { text: "must not type" }, sessionId }); |
| 96 | assert.equal(reply.error.code, "session_required"); |
| 97 | } |
| 98 | }); |
| 99 | |
| 100 | test("helper accepts actions only while their original socket owner is alive", async () => { |
| 101 | const sessionId = "lease-owner"; |
| 102 | const lease = await openAppSession(sessionId); |
| 103 | assert.equal((await appRequest({ tool: "open_session", sessionId })).error.code, "session_owned"); |
| 104 | for (const leaseToken of [undefined, "another-owner"]) { |
| 105 | assert.equal((await appRequest({ tool: "get_app_state", sessionId, leaseToken, args: { app_ref: { name: "Spoofed owner" } } })).error.code, "session_owner_required"); |
| 106 | } |
| 107 | assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Lease owner" } } })).ok, true); |
| 108 | lease.socket.destroy(); |
| 109 | await until(() => calls().some((item) => item.method === "release_input" && item.appName === "Lease owner")); |
| 110 | assert.equal((await appRequest({ tool: "type", sessionId, leaseToken: lease.token, args: { text: "stale lease" } })).error.code, "session_owner_required"); |
| 111 | assert.equal((await appSessionRequest({ tool: "probe", sessionId })).ok, true, "a dropped owner socket re-leases transparently instead of bricking the session"); |
| 112 | assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Re-leased owner" } } })).data.name, "Re-leased owner"); |
| 113 | assert.ok(!calls().some((item) => item.appName === "Spoofed owner" || item.text === "stale lease")); |
| 114 | }); |
| 115 | |
| 116 | test("separate sessions keep their own bound apps and closed sessions cannot revive", async () => { |
| 117 | const a = appExec({}, "binding-a"); |
| 118 | const b = appExec({}, "binding-b"); |
| 119 | await a.remote({ tool: "get_app_state", args: { app_ref: { name: "Editor A" } } }); |
| 120 | assert.equal((await b.remote({ tool: "type", args: { text: "unbound" } })).error.code, "target_app_required"); |
| 121 | await b.remote({ tool: "get_app_state", args: { app_ref: { name: "Editor B" } } }); |
| 122 | assert.equal((await a.remote({ tool: "type", args: { text: "first" } })).data.appName, "Editor A"); |
| 123 | assert.equal((await b.remote({ tool: "type", args: { text: "second" } })).data.appName, "Editor B"); |
| 124 | assert.equal((await appSessionRequest({ tool: "close_session", sessionId: "binding-a" })).ok, true); |
| 125 | await appExec({}, "unrelated-new-session").remote({ tool: "probe" }); |
| 126 | await assert.rejects(a.remote({ tool: "get_app_state", args: { app_ref: { name: "Revived" } } }), (err) => err.code === "app_session_closed"); |
| 127 | assert.equal((await b.remote({ tool: "type", args: { text: "still alive" } })).ok, true); |
| 128 | }); |
| 129 | |
| 130 | test("list_sessions names live sessions content-free and drops closed ones", async () => { |
| 131 | const a = appExec({}, "ls-a"); |
| 132 | const b = appExec({}, "ls-b"); |
| 133 | await a.remote({ tool: "probe" }); |
| 134 | await b.remote({ tool: "probe" }); |
| 135 | const both = await a.remote({ tool: "list_sessions" }); |
| 136 | assert.equal(both.ok, true); |
| 137 | assert.equal(both.data.control, "ready"); |
| 138 | assert.ok(both.data.count >= 2, `both live sessions are listed (${both.data.count})`); |
| 139 | for (const s of both.data.sessions) { |
| 140 | assert.ok(s.target === null || (typeof s.target === "object" && Number.isInteger(s.target.pid)), "targets are app identity or null, never task text"); |
| 141 | assert.ok(typeof s.ageSec === "number" && typeof s.inputHeld === "boolean" && typeof s.action !== "undefined"); |
| 142 | } |
| 143 | const forged = await appRequest({ tool: "list_sessions", sessionId: "ls-a", leaseToken: "forged" }); |
| 144 | assert.equal(forged.ok, false); |
| 145 | assert.equal(forged.error.code, "session_owner_required", "the registry needs the live owner lease, not a session id alone"); |
| 146 | assert.equal((await appSessionRequest({ tool: "close_session", sessionId: "ls-b" })).ok, true); |
| 147 | const one = await a.remote({ tool: "list_sessions" }); |
| 148 | assert.equal(one.data.count, both.data.count - 1, "a closed session leaves the registry"); |
| 149 | }); |
| 150 | |
| 151 | test("a capability grant narrows the daemon lease, and cleanup is never blocked", async () => { |
| 152 | const prior = process.env.CODEWHALE_CU_GRANT; |
| 153 | process.env.CODEWHALE_CU_GRANT = "probe"; |
| 154 | try { |
| 155 | await openAppSession("grant-a"); |
| 156 | assert.equal((await appSessionRequest({ tool: "probe", sessionId: "grant-a" })).ok, true); |
| 157 | const refused = await appSessionRequest({ tool: "get_app_state", sessionId: "grant-a", args: { app_ref: { name: "Nope" } } }); |
| 158 | assert.equal(refused.ok, false); |
| 159 | assert.equal(refused.error.code, "not_granted", "the daemon refuses ungranted tools even if the server asked"); |
| 160 | assert.equal((await appSessionRequest({ tool: "close_session", sessionId: "grant-a" })).ok, true, "cleanup must never be blocked by a grant"); |
| 161 | process.env.CODEWHALE_CU_GRANT = ""; |
| 162 | await openAppSession("grant-b"); |
| 163 | assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId: "grant-b", args: { app_ref: { name: "Open" } } })).ok, true, "a new session without a grant is unrestricted"); |
| 164 | await appSessionRequest({ tool: "close_session", sessionId: "grant-b" }); |
| 165 | } finally { |
| 166 | if (prior === undefined) delete process.env.CODEWHALE_CU_GRANT; else process.env.CODEWHALE_CU_GRANT = prior; |
| 167 | } |
| 168 | }); |
| 169 | |
| 170 | test("read-only grants survive the wire-name translation (request_access travels as probe)", async () => { |
| 171 | const prior = process.env.CODEWHALE_CU_GRANT; |
| 172 | process.env.CODEWHALE_CU_GRANT = "read-only"; |
| 173 | try { |
| 174 | await openAppSession("grant-ro"); |
| 175 | assert.equal((await appSessionRequest({ tool: "probe", sessionId: "grant-ro" })).ok, true, "the grant must cover the transport name the daemon actually sees"); |
| 176 | assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId: "grant-ro", args: { app_ref: { name: "Visible" } } })).ok, true, "observation tools stay granted"); |
| 177 | const refused = await appSessionRequest({ tool: "left_click", sessionId: "grant-ro", args: { target: { type: "coordinate", x: 1, y: 1 } } }); |
| 178 | assert.equal(refused.ok, false); |
| 179 | assert.equal(refused.error.code, "not_granted"); |
| 180 | await appSessionRequest({ tool: "close_session", sessionId: "grant-ro" }); |
| 181 | } finally { |
| 182 | if (prior === undefined) delete process.env.CODEWHALE_CU_GRANT; else process.env.CODEWHALE_CU_GRANT = prior; |
| 183 | } |
| 184 | }); |
| 185 | |
| 186 | test("disconnect cancels the child process and a queued request never posts input", async () => { |
| 187 | const active = new AbortController(); |
| 188 | const queued = new AbortController(); |
| 189 | const held = appSessionRequest({ tool: "hold_key", sessionId: "socket-active", args: { text: "socket-cancel" } }, { signal: active.signal }); |
| 190 | const heldRejection = assert.rejects(held, (err) => err.code === "cancelled"); |
| 191 | await until(() => calls().some((item) => item.method === "child_started" && item.text === "socket-cancel")); |
| 192 | const waiting = appSessionRequest({ tool: "get_app_state", sessionId: "socket-queued", args: { app_ref: { name: "Cancelled queue" } } }, { signal: queued.signal }); |
| 193 | const queuedRejection = assert.rejects(waiting, (err) => err.code === "cancelled"); |
| 194 | queued.abort(); |
| 195 | active.abort(); |
| 196 | await Promise.all([heldRejection, queuedRejection]); |
| 197 | await until(() => calls().some((item) => item.method === "child_released")); |
| 198 | assert.equal((await appExec({}, "socket-check").remote({ tool: "probe" })).ok, true); |
| 199 | assert.ok(!calls().some((item) => item.appName === "Cancelled queue")); |
| 200 | assert.ok(!calls().some((item) => item.method === "late_input")); |
| 201 | }); |
| 202 | |
| 203 | test("MCP cancellation drains input, keeps the host alive, and isolates a second host", async () => { |
| 204 | const a = mcp(); |
| 205 | const b = mcp(); |
| 206 | await a.tool("consent", { action: "allow", app: "Host A" }); |
| 207 | await a.tool("get_app_state", { app_ref: { name: "Host A" } }); |
| 208 | assert.equal((await b.tool("type", { text: "unbound host B" })).error.code, "target_app_required"); |
| 209 | await b.tool("consent", { action: "allow", app: "Host B" }); |
| 210 | await b.tool("get_app_state", { app_ref: { name: "Host B" } }); |
| 211 | const id = a.start("hold_key", { text: "mcp-cancel", duration: 10 }); |
| 212 | await until(() => calls().some((item) => item.method === "child_started" && item.text === "mcp-cancel")); |
| 213 | a.cancel(id); |
| 214 | assert.equal((await a.tool("type", { text: "after cancellation" })).ok, true); |
| 215 | assert.ok(!a.replies.has(id), "cancelled MCP request must not reply"); |
| 216 | assert.equal((await b.tool("type", { text: "other host" })).appName, "Host B"); |
| 217 | await closeHost(a); |
| 218 | assert.equal((await b.tool("type", { text: "after other host exits" })).ok, true); |
| 219 | await closeHost(b); |
| 220 | }); |
| 221 | |
| 222 | test("stop cancels active and queued actions, releases held input, and leaves probes usable", async () => { |
| 223 | const host = mcp(); |
| 224 | await host.tool("consent", { action: "allow", app: "Stopped host" }); |
| 225 | await host.tool("get_app_state", { app_ref: { name: "Stopped host" } }); |
| 226 | await host.tool("left_mouse_down", { target: { type: "coordinate", space: "screen", x: 10, y: 10 } }); |
| 227 | const hold = host.start("hold_key", { text: "mcp-stop", duration: 10 }); |
| 228 | await until(() => calls().some((item) => item.method === "child_started" && item.text === "mcp-stop")); |
| 229 | const queued = host.start("type", { text: "must never arrive after stop" }); |
| 230 | const stopped = await host.tool("stop_computer_control"); |
| 231 | assert.equal(stopped.ok, true, JSON.stringify(stopped)); |
| 232 | assert.equal(stopped.inputReleased, true); |
| 233 | assert.equal((await host.response(queued)).error.code, "control_stopped"); |
| 234 | assert.equal((await host.response(hold)).ok, false); |
| 235 | assert.ok(calls().some((item) => item.method === "release_input" && item.appName === "Stopped host" && item.pointerDown)); |
| 236 | assert.ok(!calls().some((item) => item.text === "must never arrive after stop")); |
| 237 | assert.equal((await host.tool("request_access")).ok, true); |
| 238 | assert.equal((await host.tool("type", { text: "after stop" })).error.code, "control_stopped"); |
| 239 | assert.ok(!calls().some((item)=>item.method==="session_closed"&&item.appName==="Stopped host"),"stop releases input but does not close the session's other owned resources"); |
| 240 | await closeHost(host); |
| 241 | assert.ok(calls().some((item)=>item.method==="session_closed"&&item.appName==="Stopped host")); |
| 242 | }); |
| 243 | |
| 244 | test("another MCP host cannot redirect the selected computer", async () => { |
| 245 | const a = mcp(); |
| 246 | const b = mcp(); |
| 247 | await a.tool("consent", { action: "allow", app: "Local host A" }); |
| 248 | await a.tool("get_app_state", { app_ref: { name: "Local host A" } }); |
| 249 | await b.tool("computer_register", { computer: "session-test-pad", transport: "hdc" }); |
| 250 | await b.tool("computer_switch", { computer: "session-test-pad" }); |
| 251 | assert.equal((await a.tool("computer_list")).active, "local"); |
| 252 | assert.equal((await b.tool("computer_list")).active, "session-test-pad"); |
| 253 | const input = await a.tool("type", { text: "stay on local host A" }); |
| 254 | assert.equal(input.ok, true, JSON.stringify(input)); |
| 255 | assert.equal(input.computer.id, "local"); |
| 256 | await b.tool("computer_remove", { computer: "session-test-pad" }); |
| 257 | await closeHost(a); |
| 258 | await closeHost(b); |
| 259 | }); |
| 260 | |
| 261 | test("retiring a helper-backed local alias closes only that MCP host's session", async () => { |
| 262 | const retiring = mcp(); |
| 263 | const survivor = mcp(); |
| 264 | let retiringErrors = ""; |
| 265 | let survivorErrors = ""; |
| 266 | retiring.child.stderr.on("data", chunk => { retiringErrors += chunk; }); |
| 267 | survivor.child.stderr.on("data", chunk => { survivorErrors += chunk; }); |
| 268 | const alias = "retiring-helper-alias"; |
| 269 | assert.equal((await retiring.tool("computer_register", { computer: alias, transport: "local" })).ok, true); |
| 270 | await retiring.tool("consent", { action: "allow", app: "Retiring alias owner", computer: alias }); |
| 271 | assert.equal((await retiring.tool("get_app_state", { computer: alias, app_ref: { name: "Retiring alias owner" } })).ok, true); |
| 272 | await survivor.tool("consent", { action: "allow", app: "Alias retirement survivor" }); |
| 273 | assert.equal((await survivor.tool("get_app_state", { app_ref: { name: "Alias retirement survivor" } })).ok, true); |
| 274 | const owner = calls().find(item => item.method === "get_app_state" && item.appName === "Retiring alias owner").instance; |
| 275 | const other = calls().find(item => item.method === "get_app_state" && item.appName === "Alias retirement survivor").instance; |
| 276 | assert.notEqual(owner, other); |
| 277 | assert.equal((await retiring.tool("left_mouse_down", { target: { type: "coordinate", space: "screen", x: 10, y: 10 } })).ok, true); |
| 278 | assert.equal((await survivor.tool("type", { text: "blocked by retiring owner" })).error.code, "input_busy"); |
| 279 | |
| 280 | // Registration only changes the private fixture catalog. No HDC observation |
| 281 | // or backend operation is requested, so no device command can run here. |
| 282 | const registered = await retiring.tool("computer_register", { computer: alias, transport: "hdc", target: "unobserved-fixture-device" }); |
| 283 | assert.equal(registered.ok, true, JSON.stringify(registered)); |
| 284 | assert.equal(registered.registered.transport, "hdc"); |
| 285 | assert.ok(calls().some(item => item.instance === owner && item.method === "release_input" && item.pointerDown), "retiring the route releases the old helper's held pointer"); |
| 286 | assert.ok(calls().some(item => item.instance === owner && item.method === "session_closed"), "retiring the route closes its helper backend"); |
| 287 | assert.ok(!calls().some(item => item.instance === other && item.method === "session_closed"), "the second MCP host keeps its helper session"); |
| 288 | |
| 289 | for (const [name, args] of [ |
| 290 | ["request_access", {}], |
| 291 | ["type", { text: "must not revive retired helper" }], |
| 292 | ]) { |
| 293 | const reply = await retiring.tool(name, { computer: "local", ...args }); |
| 294 | assert.equal(reply.ok, false); |
| 295 | assert.equal(reply.error.code, "app_session_closed"); |
| 296 | assert.match(reply.error.message, /new MCP session/); |
| 297 | } |
| 298 | assert.ok(!calls().some(item => item.text === "must not revive retired helper")); |
| 299 | assert.equal((await survivor.tool("type", { text: "survives alias retirement" })).appName, "Alias retirement survivor"); |
| 300 | assert.equal((await retiring.tool("computer_remove", { computer: alias })).ok, true); |
| 301 | |
| 302 | const retiringClosed = new Promise(resolve => retiring.child.once("close", resolve)); |
| 303 | await closeHost(retiring); |
| 304 | await retiringClosed; |
| 305 | assert.equal(retiringErrors, "", "shutdown must not retry an already closed helper as a cleanup failure"); |
| 306 | assert.equal((await survivor.tool("type", { text: "survives retiring host shutdown" })).appName, "Alias retirement survivor"); |
| 307 | const survivorClosed = new Promise(resolve => survivor.child.once("close", resolve)); |
| 308 | await closeHost(survivor); |
| 309 | await survivorClosed; |
| 310 | assert.equal(survivorErrors, ""); |
| 311 | }); |
| 312 | |
| 313 | test("MCP forced exit releases idle held input without waiting for another client", async () => { |
| 314 | const dead = mcp(); |
| 315 | const survivor = mcp(); |
| 316 | await dead.tool("consent", { action: "allow", app: "Killed idle host" }); |
| 317 | await dead.tool("get_app_state", { app_ref: { name: "Killed idle host" } }); |
| 318 | await survivor.tool("consent", { action: "allow", app: "Surviving host" }); |
| 319 | await survivor.tool("get_app_state", { app_ref: { name: "Surviving host" } }); |
| 320 | await dead.tool("left_mouse_down", { target: { type: "coordinate", space: "screen", x: 10, y: 10 } }); |
| 321 | assert.equal((await survivor.tool("type", {text:"must wait for held pointer"})).error.code,"input_busy"); |
| 322 | assert.equal((await survivor.tool("request_access")).ok,true,"observation stays available while another session holds input"); |
| 323 | const exit = new Promise((resolve) => dead.child.once("exit", resolve)); |
| 324 | dead.child.kill("SIGKILL"); |
| 325 | await exit; |
| 326 | hosts.delete(dead.child); |
| 327 | await until(() => calls().some((item) => item.method === "release_input" && item.appName === "Killed idle host" && item.pointerDown)); |
| 328 | assert.equal((await survivor.tool("type", { text: "surviving binding" })).appName, "Surviving host"); |
| 329 | await closeHost(survivor); |
| 330 | }); |
| 331 | |
| 332 | test("MCP forced exit cancels its active child before delayed input can post", async () => { |
| 333 | const host = mcp(); |
| 334 | await host.tool("consent", { action: "allow", app: "Killed active host" }); |
| 335 | await host.tool("get_app_state", { app_ref: { name: "Killed active host" } }); |
| 336 | host.start("hold_key", { text: "killed-active", duration: 10 }); |
| 337 | await until(() => calls().some((item) => item.method === "child_started" && item.text === "killed-active")); |
| 338 | const instance = calls().find((item) => item.method === "child_started" && item.text === "killed-active").instance; |
| 339 | const exit = new Promise((resolve) => host.child.once("exit", resolve)); |
| 340 | host.child.kill("SIGKILL"); |
| 341 | await exit; |
| 342 | hosts.delete(host.child); |
| 343 | await until(() => calls().some((item) => item.method === "child_released" && item.instance === instance)); |
| 344 | await until(() => calls().some((item) => item.method === "release_input" && item.appName === "Killed active host")); |
| 345 | assert.ok(!calls().some((item) => item.method === "late_input")); |
| 346 | }); |
| 347 | |
| 348 | test("an app update re-leases live sessions transparently; an absent app still fails without bricking", async () => { |
| 349 | const sessionId = "upgrade-survivor"; |
| 350 | assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Survivor" } } })).ok, true); |
| 351 | const exit = new Promise((resolve) => daemon.once("exit", resolve)); |
| 352 | daemon.kill("SIGTERM"); |
| 353 | await exit; |
| 354 | // Mid-update the app is genuinely absent: the request fails, and that |
| 355 | // failure is not cached against the session. |
| 356 | await assert.rejects(appSessionRequest({ tool: "probe", sessionId }), (err) => err.code === "app_unavailable"); |
| 357 | daemon = spawn(process.execPath, [path.join(ROOT, "app/daemon.mjs")], { env: {...env, CODEWHALE_CU_CONTROL_FD: "3"}, stdio: ["ignore", "ignore", "pipe", "overlapped"] }); |
| 358 | daemon.stderr.on("data", (data) => { daemonErrors += data; }); |
| 359 | await until(async () => !!(await hello({ timeoutMs: 100 })), 5000).catch((err) => { throw new Error(`${err.message}\n${daemonErrors}`); }); |
| 360 | const reply = await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Survivor again" } } }); |
| 361 | assert.equal(reply.ok, true, JSON.stringify(reply)); |
| 362 | assert.equal(reply.data.name, "Survivor again", "the same session id works on the replacement daemon without a host reload"); |
| 363 | }); |
| 364 | |
| 365 | test("MCP EOF releases a completed mouse-down and helper shutdown aborts active children", async () => { |
| 366 | const host = mcp(); |
| 367 | await host.tool("consent", { action: "allow", app: "Disconnected host" }); |
| 368 | await host.tool("get_app_state", { app_ref: { name: "Disconnected host" } }); |
| 369 | await host.tool("left_mouse_down", { target: { type: "coordinate", space: "screen", x: 10, y: 10 } }); |
| 370 | await closeHost(host); |
| 371 | assert.ok(calls().some((item) => item.method === "release_input" && item.appName === "Disconnected host" && item.pointerDown)); |
| 372 | const held = appSessionRequest({ tool: "hold_key", sessionId: "helper-exit", args: { text: "helper-exit" } }); |
| 373 | const result = held.catch((err) => ({ error: err.code })); |
| 374 | await until(() => calls().some((item) => item.method === "child_started" && item.text === "helper-exit")); |
| 375 | const instance = calls().find((item) => item.method === "child_started" && item.text === "helper-exit").instance; |
| 376 | const exit = new Promise((resolve) => daemon.once("exit", resolve)); |
| 377 | daemon.stdio[3].destroy(); // Closing the human owner is graceful on every OS. |
| 378 | assert.equal(await exit, 0, daemonErrors); |
| 379 | await result; |
| 380 | assert.ok(calls().some((item) => item.method === "child_released" && item.instance === instance)); |
| 381 | assert.ok(!calls().some((item) => item.method === "late_input")); |
| 382 | }); |
| 383 |