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