| 1 | // Real MCP + real transports, with HDC/SSH executables or local backends replaced. |
| 2 | // Every catalog, downloaded byte and command log belongs to this fixture. |
| 3 | import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs"; |
| 4 | import { test } from "node:test"; |
| 5 | import assert from "node:assert/strict"; |
| 6 | import { spawn, spawnSync } from "node:child_process"; |
| 7 | import { pathToFileURL } from "node:url"; |
| 8 | import { once } from "node:events"; |
| 9 | import { createInterface } from "node:readline"; |
| 10 | import fs from "node:fs"; |
| 11 | import os from "node:os"; |
| 12 | import path from "node:path"; |
| 13 | import { setTimeout as delay } from "node:timers/promises"; |
| 14 | import { routeFingerprint } from "../src/transport.mjs"; |
| 15 | |
| 16 | const ROOT = path.resolve(import.meta.dirname, ".."); |
| 17 | |
| 18 | // Every control/catalog write the child or server reads must be atomic: |
| 19 | // a plain writeFileSync is observable mid-write by the polling readers and |
| 20 | // surfaces as "Unexpected end of JSON input" instead of the fixture's error. |
| 21 | // On Windows a rename over a file a fixture process holds open for reading |
| 22 | // fails EPERM/EACCES/EBUSY (the v0.11.2 tag CI failure); the reader closes |
| 23 | // within milliseconds, so retry briefly instead of failing the test. |
| 24 | function writeJsonAtomic(file, value) { |
| 25 | const tmp = `${file}.${process.pid}.tmp`; |
| 26 | fs.writeFileSync(tmp, JSON.stringify(value)); |
| 27 | for (let attempt = 0; ; attempt++) { |
| 28 | try { fs.renameSync(tmp, file); return; } |
| 29 | catch (error) { |
| 30 | if (attempt >= 50 || !["EPERM", "EACCES", "EBUSY"].includes(error?.code)) throw error; |
| 31 | Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20); |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | function fixture(t, backendSource, sshSource) { |
| 37 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-route-")); |
| 38 | const log = path.join(dir, "calls.jsonl"); |
| 39 | const control = path.join(dir, "control.json"); |
| 40 | const bin = path.join(dir, "bin"); |
| 41 | fs.mkdirSync(bin); |
| 42 | fs.writeFileSync(path.join(bin, "hdc.cjs"), `#!${process.execPath} |
| 43 | const fs = require('node:fs'); |
| 44 | const args = process.argv.slice(2); |
| 45 | const target = args[0] === '-t' ? args.splice(0, 2)[1] : 'default'; |
| 46 | fs.appendFileSync(process.env.ROUTE_LOG, JSON.stringify({target,args}) + '\\n'); |
| 47 | const control = fs.existsSync(process.env.ROUTE_CONTROL) ? JSON.parse(fs.readFileSync(process.env.ROUTE_CONTROL)) : {}; |
| 48 | if (control.fail === target) process.exit(7); |
| 49 | if (args[0] === 'list') console.log(target); |
| 50 | if (args[0] === 'file' && args[1] === 'recv') { |
| 51 | if (control.changeTo) { |
| 52 | const file = process.env.CODEWHALE_CU_STATE_DIR + '/computers.json'; |
| 53 | const catalog = JSON.parse(fs.readFileSync(file)); |
| 54 | catalog.computers.pad.target = control.changeTo; |
| 55 | const tmp = file + '.' + process.pid + '.tmp'; |
| 56 | fs.writeFileSync(tmp, JSON.stringify(catalog)); |
| 57 | fs.renameSync(tmp, file); |
| 58 | } |
| 59 | const layout = { attributes: { bundleName: target, type: 'Button', text: 'OK', bounds: '[0,0][20,20]' } }; |
| 60 | const jpeg = Buffer.from([255,216,255,192,0,11,8,0,120,0,168,1,1,17,0,255,217]); |
| 61 | fs.writeFileSync(args[3], args[2].includes('layout') ? JSON.stringify(layout) : jpeg); |
| 62 | } |
| 63 | `, { mode: 0o755 }); |
| 64 | if (sshSource) fs.writeFileSync(path.join(bin, "ssh.cjs"), `#!${process.execPath}\n${sshSource}`, { mode: 0o755 }); |
| 65 | const env = { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}`, |
| 66 | CU_COMMAND_FIXTURES: bin, |
| 67 | NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${pathToFileURL(path.join(ROOT, "tests/fixtures/command-shims.mjs")).href}`, |
| 68 | ROUTE_LOG: log, ROUTE_CONTROL: control, CODEWHALE_CU_APP: "off", CODEWHALE_CU_APP_WARM: "off", |
| 69 | CODEWHALE_CU_STATE_DIR: dir, CODEWHALE_CU_RECORDINGS_DIR: dir }; |
| 70 | delete env.CODEWHALE_CU_TEST_REMOTE; |
| 71 | delete env.CODEWHALE_CU_TEST_BACKEND; |
| 72 | if (backendSource) { |
| 73 | env.CODEWHALE_CU_TEST_BACKEND = path.join(dir, "backend.mjs"); |
| 74 | fs.writeFileSync(env.CODEWHALE_CU_TEST_BACKEND, backendSource); |
| 75 | } |
| 76 | const child = spawn(process.execPath, [path.join(ROOT, "mcp/server.mjs")], { env, stdio: ["pipe", "pipe", "pipe"] }); |
| 77 | child.stdin.write(hostKeysLine()); |
| 78 | let nextId = 0; |
| 79 | const pending = new Map(); |
| 80 | const lines = createInterface({ input: child.stdout }); |
| 81 | lines.on("line", line => { |
| 82 | const response = JSON.parse(line); |
| 83 | pending.get(response.id)?.(response); |
| 84 | }); |
| 85 | let stderr = ""; |
| 86 | child.stderr.on("data", data => { stderr += data; }); |
| 87 | t.after(async () => { |
| 88 | const exited = once(child, "exit"); |
| 89 | child.stdin.end(); |
| 90 | await exited; |
| 91 | fs.rmSync(dir, { recursive: true, force: true }); |
| 92 | assert.equal(stderr, ""); |
| 93 | }); |
| 94 | return { |
| 95 | dir, env, |
| 96 | control(value) { writeJsonAtomic(control, value); }, |
| 97 | calls() { return fs.existsSync(log) ? fs.readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse) : []; }, |
| 98 | async tool(name, args = {}) { |
| 99 | const id = ++nextId; |
| 100 | let timer; |
| 101 | try { |
| 102 | const response = await new Promise((resolve, reject) => { |
| 103 | timer = setTimeout(() => reject(new Error(`${name} timed out: ${stderr}`)), 8_000); |
| 104 | pending.set(id, resolve); |
| 105 | child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: attest({ name, arguments: args }) }) + "\n"); |
| 106 | }); |
| 107 | assert.ok(response.result, JSON.stringify(response)); |
| 108 | return JSON.parse(response.result.content[0].text); |
| 109 | } finally { clearTimeout(timer); pending.delete(id); } |
| 110 | }, |
| 111 | async register(target, label = "Fixture") { |
| 112 | const result = await this.tool("computer_register", { computer: "pad", transport: "hdc", target, label }); |
| 113 | assert.equal(result.ok, true, JSON.stringify(result)); |
| 114 | }, |
| 115 | externalRegister(target) { |
| 116 | const result = spawnSync(process.execPath, ["--input-type=module", "-e", |
| 117 | "import {register} from './src/registry.mjs'; register({id:'pad', transport:'hdc', target:process.argv[1]});", target], |
| 118 | { cwd: ROOT, env, encoding: "utf8" }); |
| 119 | assert.equal(result.status, 0, result.stderr); |
| 120 | }, |
| 121 | }; |
| 122 | } |
| 123 | |
| 124 | const point = { type: "coordinate", x: 10, y: 10 }; |
| 125 | |
| 126 | test("a warmed HDC backend cannot send input to A after registration reports B", async t => { |
| 127 | const f = fixture(t); |
| 128 | await f.register("A"); |
| 129 | assert.equal((await f.tool("request_access", { computer: "pad" })).connected, true); |
| 130 | await f.register("B"); |
| 131 | const result = await f.tool("key", { text: "ENTER" }); |
| 132 | const input = f.calls().filter(call => call.args.includes("uiInput")); |
| 133 | assert.equal(result.error?.code, "computer_observation_required", JSON.stringify({ result, input })); |
| 134 | assert.deepEqual(input, []); |
| 135 | }); |
| 136 | |
| 137 | test("same-ID HDC replacement requires fresh observation and dispatches only to B", async t => { |
| 138 | const f = fixture(t); |
| 139 | await f.register("A"); |
| 140 | const initial = await f.tool("screenshot", { computer: "pad" }); |
| 141 | assert.equal(initial.ok, true, JSON.stringify(initial)); |
| 142 | const state = await f.tool("get_app_state"); |
| 143 | assert.equal((await f.tool("left_click", { target: point })).ok, true); |
| 144 | await f.register("B"); |
| 145 | const before = f.calls().length; |
| 146 | assert.equal((await f.tool("key", { text: "ENTER" })).error.code, "computer_observation_required"); |
| 147 | assert.equal(f.calls().length, before); |
| 148 | assert.equal((await f.tool("get_app_state")).bundle_id, "B"); |
| 149 | assert.equal((await f.tool("left_click", { target: point })).error.code, "no_raster"); |
| 150 | assert.equal((await f.tool("perform_action", { target: { type: "element", state_id: state.state_id, index: 0 }, action: "click" })).error.code, "unknown_state"); |
| 151 | assert.equal((await f.tool("screenshot")).ok, true); |
| 152 | assert.equal((await f.tool("left_click", { target: point })).ok, true); |
| 153 | const input = f.calls().filter(call => call.args.includes("uiInput")); |
| 154 | assert.deepEqual(input.map(call => call.target), ["A", "B"]); |
| 155 | assert.ok(f.calls().slice(before).every(call => call.target === "B")); |
| 156 | }); |
| 157 | |
| 158 | test("a different catalog writer invalidates the active host's HDC route on use", async t => { |
| 159 | const f = fixture(t); |
| 160 | await f.register("A"); |
| 161 | await f.tool("screenshot", { computer: "pad" }); |
| 162 | f.externalRegister("B"); |
| 163 | assert.equal((await f.tool("type", { text: "fixture" })).error.code, "computer_observation_required"); |
| 164 | assert.equal((await f.tool("computer_list")).active, "pad"); |
| 165 | await f.tool("screenshot"); |
| 166 | assert.equal((await f.tool("key", { text: "ENTER" })).ok, true); |
| 167 | assert.deepEqual(f.calls().filter(call => call.args.includes("uiInput")).map(call => call.target), ["B"]); |
| 168 | }); |
| 169 | |
| 170 | test("label-only catalog changes reuse cached backend geometry and observation", async t => { |
| 171 | const f = fixture(t); |
| 172 | await f.register("A"); |
| 173 | await f.tool("screenshot", { computer: "pad" }); |
| 174 | await f.register("A", "Renamed"); |
| 175 | const before = f.calls().length; |
| 176 | assert.equal((await f.tool("list_displays")).ok, true); |
| 177 | assert.equal(f.calls().length, before, "cached display geometry proves backend reuse"); |
| 178 | assert.equal((await f.tool("left_click", { target: point })).ok, true); |
| 179 | }); |
| 180 | |
| 181 | test("failed observation of a replacement never falls back to the old backend", async t => { |
| 182 | const f = fixture(t); |
| 183 | await f.register("A"); |
| 184 | await f.tool("screenshot", { computer: "pad" }); |
| 185 | f.externalRegister("B"); |
| 186 | f.control({ fail: "B" }); |
| 187 | const before = f.calls().length; |
| 188 | assert.equal((await f.tool("screenshot")).ok, false); |
| 189 | assert.equal((await f.tool("key", { text: "ENTER" })).error.code, "computer_observation_required"); |
| 190 | assert.ok(f.calls().slice(before).every(call => call.target === "B")); |
| 191 | assert.equal(f.calls().filter(call => call.args.includes("uiInput")).length, 0); |
| 192 | }); |
| 193 | |
| 194 | test("an observation completed after an external route change cannot authorize input", async t => { |
| 195 | const f = fixture(t); |
| 196 | await f.register("A"); |
| 197 | f.control({ changeTo: "B" }); |
| 198 | const stale = await f.tool("get_app_state", { computer: "pad" }); |
| 199 | assert.equal(stale.error.code, "computer_route_changed"); |
| 200 | assert.equal(stale.request_dispatched, true); |
| 201 | assert.equal(stale.outcome_unknown, true); |
| 202 | f.control({}); |
| 203 | assert.equal((await f.tool("key", { text: "ENTER" })).error.code, "computer_observation_required"); |
| 204 | assert.equal((await f.tool("get_app_state")).bundle_id, "B"); |
| 205 | assert.equal((await f.tool("key", { text: "ENTER" })).ok, true); |
| 206 | assert.deepEqual(f.calls().filter(call => call.args.includes("uiInput")).map(call => call.target), ["B"]); |
| 207 | }); |
| 208 | |
| 209 | test("remove and re-register cannot reuse observations even for the same route", async t => { |
| 210 | const f = fixture(t); |
| 211 | await f.register("A"); |
| 212 | await f.tool("screenshot", { computer: "pad" }); |
| 213 | await f.tool("computer_remove", { computer: "pad" }); |
| 214 | await f.register("A"); |
| 215 | assert.equal((await f.tool("left_click", { computer: "pad", target: point })).error.code, "computer_observation_required"); |
| 216 | }); |
| 217 | |
| 218 | test("route fingerprints include transport fields and effective defaults only", () => { |
| 219 | const base = { transport: "ssh", host: "a" }; |
| 220 | assert.equal(routeFingerprint(base), routeFingerprint({ ...base, label: "renamed", registeredAt: "later", platformHint: "linux", agentPath: ".codewhale-cu/agent/agent.mjs" })); |
| 221 | for (const changed of [{ host: "b" }, { port: 2222 }, { user: "other" }, { agentPath: "other/agent.mjs" }, { platformHint: "darwin" }, { transport: "hdc" }]) { |
| 222 | assert.notEqual(routeFingerprint(base), routeFingerprint({ ...base, ...changed })); |
| 223 | } |
| 224 | assert.equal(routeFingerprint({ transport: "hdc" }), routeFingerprint({ transport: "hdc", target: "", platform: "harmonyos" })); |
| 225 | assert.notEqual(routeFingerprint({ transport: "hdc", target: "A" }), routeFingerprint({ transport: "hdc", target: "B" })); |
| 226 | }); |
| 227 | |
| 228 | test("failed cleanup and catalog rollback cannot resurrect the retired backend", async t => { |
| 229 | const f = fixture(t, ` |
| 230 | import fs from 'node:fs'; |
| 231 | let instance = 0; |
| 232 | export function create() { |
| 233 | const id = ++instance; |
| 234 | const record = method => fs.appendFileSync(process.env.ROUTE_LOG, JSON.stringify({method,id}) + '\\n'); |
| 235 | return { |
| 236 | get_app_state: async () => ({found:true, elements:[], instance:id}), |
| 237 | key: async () => { record('key'); return {action_sent:true}; }, |
| 238 | releaseInput: async () => { |
| 239 | record('releaseInput'); |
| 240 | if (JSON.parse(fs.readFileSync(process.env.ROUTE_CONTROL)).failRelease) |
| 241 | throw Object.assign(new Error('fixture release failed'), {code:'release_failed'}); |
| 242 | }, |
| 243 | closeSession: async () => { |
| 244 | record('closeSession'); |
| 245 | if (JSON.parse(fs.readFileSync(process.env.ROUTE_CONTROL)).failCleanup) |
| 246 | throw Object.assign(new Error('fixture cleanup failed'), {code:'cleanup_failed'}); |
| 247 | } |
| 248 | }; |
| 249 | } |
| 250 | `); |
| 251 | f.control({ failCleanup: false }); |
| 252 | assert.equal((await f.tool("computer_register", { computer: "pad", transport: "local" })).ok, true); |
| 253 | assert.equal((await f.tool("get_app_state", { computer: "pad" })).instance, 1); |
| 254 | f.control({ failRelease: true }); |
| 255 | assert.equal((await f.tool("computer_register", { computer: "pad", transport: "hdc", target: "B" })).error.code, "release_failed"); |
| 256 | assert.deepEqual(f.calls().slice(-2).map(call => call.method), ["releaseInput", "closeSession"], "recorder cleanup is attempted even when input release fails"); |
| 257 | f.control({ failCleanup: true }); |
| 258 | assert.equal((await f.tool("computer_register", { computer: "pad", transport: "hdc", target: "B" })).error.code, "cleanup_failed"); |
| 259 | assert.equal((await f.tool("key", { text: "ENTER" })).error.code, "cleanup_failed"); |
| 260 | // Roll back the catalog exactly, bypassing the host which still owns A. |
| 261 | const file = path.join(f.dir, "computers.json"); |
| 262 | const catalog = JSON.parse(fs.readFileSync(file)); |
| 263 | catalog.computers.pad = { id: "pad", transport: "local" }; |
| 264 | writeJsonAtomic(file, catalog); |
| 265 | assert.equal((await f.tool("key", { text: "ENTER" })).error.code, "cleanup_failed"); |
| 266 | assert.equal(f.calls().filter(call => call.method === "key").length, 0); |
| 267 | f.control({ failCleanup: false }); |
| 268 | assert.equal((await f.tool("key", { text: "ENTER" })).error.code, "computer_observation_required"); |
| 269 | assert.equal((await f.tool("get_app_state")).instance, 2); |
| 270 | assert.equal((await f.tool("key", { text: "ENTER" })).ok, true); |
| 271 | assert.deepEqual(f.calls().filter(call => call.method === "key").map(call => call.id), [2]); |
| 272 | assert.deepEqual(f.calls().slice(0, 2).map(call => call.method), ["releaseInput", "closeSession"]); |
| 273 | }); |
| 274 | |
| 275 | test("a route change during element revalidation refuses dispatch to the old backend", async t => { |
| 276 | const f = fixture(t, ` |
| 277 | import fs from 'node:fs'; |
| 278 | export function create() { |
| 279 | const element = {index:0, path:[0], role:'Button', label:'OK', position:{x:0,y:0}, size:{w:20,h:20}}; |
| 280 | return { |
| 281 | get_app_state: async () => ({found:true, elements:[element]}), |
| 282 | resolve_element: async () => { |
| 283 | const file = process.env.CODEWHALE_CU_STATE_DIR + '/computers.json'; |
| 284 | const catalog = JSON.parse(fs.readFileSync(file)); |
| 285 | catalog.computers.pad = {id:'pad',transport:'hdc',target:'B'}; |
| 286 | const tmp = file + '.' + process.pid + '.tmp'; |
| 287 | fs.writeFileSync(tmp, JSON.stringify(catalog)); |
| 288 | fs.renameSync(tmp, file); |
| 289 | return {found:true,element}; |
| 290 | }, |
| 291 | perform_action: async () => { throw new Error('must never dispatch stale action'); } |
| 292 | }; |
| 293 | } |
| 294 | `); |
| 295 | await f.tool("computer_register", { computer: "pad", transport: "local" }); |
| 296 | const state = await f.tool("get_app_state", { computer: "pad" }); |
| 297 | const action = await f.tool("perform_action", { target: { type: "element", state_id: state.state_id, index: 0 }, action: "click" }); |
| 298 | assert.equal(action.error.code, "computer_route_changed"); |
| 299 | assert.equal(action.request_dispatched, undefined); |
| 300 | assert.equal((await f.tool("key", { text: "ENTER" })).error.code, "computer_observation_required"); |
| 301 | assert.deepEqual(f.calls(), []); |
| 302 | }); |
| 303 | |
| 304 | const dispatchFailureBackend = ` |
| 305 | import fs from 'node:fs'; |
| 306 | import {setTimeout as delay} from 'node:timers/promises'; |
| 307 | export function create() { |
| 308 | const record = method => fs.appendFileSync(process.env.ROUTE_LOG, JSON.stringify({method}) + '\\n'); |
| 309 | return { |
| 310 | get_app_state: async () => ({found:true,elements:[]}), |
| 311 | key: async () => { |
| 312 | record('dispatched'); |
| 313 | while (!JSON.parse(fs.readFileSync(process.env.ROUTE_CONTROL)).release) await delay(5); |
| 314 | throw Object.freeze(Object.assign(new Error('fixture failed after dispatch'), {code:'fixture_dispatch_failed'})); |
| 315 | }, |
| 316 | releaseInput: async () => { record('releaseInput'); }, |
| 317 | closeSession: async () => { |
| 318 | record('closeSession'); |
| 319 | if (JSON.parse(fs.readFileSync(process.env.ROUTE_CONTROL)).failCleanup) |
| 320 | throw Object.assign(new Error('fixture cleanup failed'), {code:'fixture_cleanup_failed'}); |
| 321 | } |
| 322 | }; |
| 323 | } |
| 324 | `; |
| 325 | |
| 326 | const dispatchFailureSSH = ` |
| 327 | const fs = require('node:fs'); |
| 328 | const {setTimeout:delay} = require('node:timers/promises'); |
| 329 | const request = JSON.parse(Buffer.from(process.argv.at(-1), 'base64')); |
| 330 | const reply = value => process.stdout.write(JSON.stringify(value) + '\\n'); |
| 331 | (async () => { |
| 332 | if (request.tool === 'platform') return reply({ok:true,platform:'linux'}); |
| 333 | if (request.tool === 'get_app_state') return reply({ok:true,data:{found:true,elements:[]}}); |
| 334 | if (request.tool !== 'key') throw new Error('unexpected fixture tool'); |
| 335 | fs.appendFileSync(process.env.ROUTE_LOG, JSON.stringify({method:'dispatched',host:process.argv.find(arg => arg.startsWith('fixture-'))}) + '\\n'); |
| 336 | let control; |
| 337 | while (!(control=JSON.parse(fs.readFileSync(process.env.ROUTE_CONTROL))).release) await delay(5); |
| 338 | if (control.failure === 'reply') return reply({ok:false,error:{code:'fixture_dispatch_failed',message:'fixture failed after dispatch'}}); |
| 339 | process.stderr.write('fixture connection lost after dispatch'); |
| 340 | process.exitCode = 7; |
| 341 | })().catch(error => { process.stderr.write(error.message); process.exitCode = 9; }); |
| 342 | `; |
| 343 | |
| 344 | for (const mode of ["backend", "reply", "connection"]) { |
| 345 | for (const change of ["changed", "removed", "unchanged", ...(mode === "backend" ? ["cleanup-failed"] : [])]) { |
| 346 | test(`${mode} dispatch failure preserves the original error when the route is ${change}`, async t => { |
| 347 | const local = mode === "backend"; |
| 348 | const f = fixture(t, local ? dispatchFailureBackend : null, local ? null : dispatchFailureSSH); |
| 349 | f.control({ release: false, failure: mode }); |
| 350 | const registration = await f.tool("computer_register", local |
| 351 | ? { computer: "pad", transport: "local" } |
| 352 | : { computer: "pad", transport: "ssh", host: "fixture-a.test", installAgent: false }); |
| 353 | assert.equal(registration.ok, true, JSON.stringify(registration)); |
| 354 | assert.equal((await f.tool("get_app_state", { computer: "pad" })).ok, true); |
| 355 | const pending = f.tool("key", { text: "ENTER" }); |
| 356 | let failed; |
| 357 | try { |
| 358 | // The child has entered dispatch before the independent catalog writer |
| 359 | // changes anything. Release only after that change is visible in-fixture. |
| 360 | const deadline = Date.now() + 2_000; |
| 361 | while (!f.calls().some(call => call.method === "dispatched") && Date.now() < deadline) await delay(5); |
| 362 | assert.equal(f.calls().filter(call => call.method === "dispatched").length, 1); |
| 363 | const file = path.join(f.dir, "computers.json"); |
| 364 | const catalog = JSON.parse(fs.readFileSync(file)); |
| 365 | if (change === "removed") delete catalog.computers.pad; |
| 366 | else if (change !== "unchanged") { |
| 367 | if (local) catalog.computers.pad = { id: "pad", transport: "hdc", target: "B" }; |
| 368 | else catalog.computers.pad.host = "fixture-b.test"; |
| 369 | } |
| 370 | writeJsonAtomic(file, catalog); |
| 371 | f.control({ release: true, failure: mode, failCleanup: change === "cleanup-failed" }); |
| 372 | failed = await pending; |
| 373 | const expected = mode === "connection" |
| 374 | ? { code: "tool_error", message: "ssh fixture-a.test exited 7: fixture connection lost after dispatch" } |
| 375 | : { code: "fixture_dispatch_failed", message: "fixture failed after dispatch" }; |
| 376 | assert.equal(failed.ok, false); |
| 377 | assert.deepEqual(failed.error, expected, "route reconciliation must not mask the dispatch error"); |
| 378 | if (change === "unchanged") { |
| 379 | assert.equal(Object.hasOwn(failed, "request_dispatched"), false); |
| 380 | assert.equal(Object.hasOwn(failed, "outcome_unknown"), false); |
| 381 | assert.equal(Object.hasOwn(failed, "note"), false); |
| 382 | assert.deepEqual(f.calls().map(call => call.method), ["dispatched"], "unchanged routes keep their resources"); |
| 383 | } else { |
| 384 | assert.equal(failed.request_dispatched, true, JSON.stringify({ receipt: failed, calls: f.calls() })); |
| 385 | assert.equal(failed.outcome_unknown, true); |
| 386 | assert.match(failed.note, /effect is unconfirmed/); |
| 387 | assert.match(failed.note, /do not automatically retry/); |
| 388 | if (local) assert.deepEqual(f.calls().map(call => call.method), ["dispatched", "releaseInput", "closeSession"]); |
| 389 | const next = await f.tool("key", { text: "must remain blocked" }); |
| 390 | assert.equal(next.error.code, change === "removed" ? "unknown_computer" |
| 391 | : change === "cleanup-failed" ? "fixture_cleanup_failed" : "computer_observation_required"); |
| 392 | } |
| 393 | assert.equal(f.calls().filter(call => call.method === "dispatched").length, 1, "no automatic replay or new-target input"); |
| 394 | } finally { |
| 395 | // Also unblock the owned child when an assertion fails; no live helper, |
| 396 | // device, or network endpoint participates in this fixture. |
| 397 | f.control({ release: true, failure: mode }); |
| 398 | await pending; |
| 399 | } |
| 400 | }); |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | test("register and spawn never replace a desktop another session spawned", async t => { |
| 405 | const f = fixture(t, null, null); |
| 406 | const file = path.join(f.dir, "computers.json"); |
| 407 | const owned = { id: "desk", transport: "docker", label: "desk", container: "cu-spawn-desk-ab12cd", image: "codewhale-cu-linux", platform: "linux", owned: true, spawnedBy: "another-session", registeredAt: new Date().toISOString() }; |
| 408 | writeJsonAtomic(file, { version: 1, active: "local", computers: { desk: owned } }); |
| 409 | for (const [name, args] of [["computer_register", { computer: "desk", transport: "hdc", target: "B" }], ["computer_spawn", { computer: "desk", transport: "docker" }]]) { |
| 410 | const result = await f.tool(name, args); |
| 411 | assert.equal(result.ok, false, JSON.stringify(result)); |
| 412 | assert.equal(result.error.code, "computer_owned_elsewhere"); |
| 413 | assert.deepEqual(JSON.parse(fs.readFileSync(file)).computers.desk, owned, `${name} must leave the owning session's entry intact`); |
| 414 | } |
| 415 | }); |
| 416 | |
| 417 | test("SSH registration retains its trusted host-key file after platform discovery", async t => { |
| 418 | const f = fixture(t, null, ` |
| 419 | const fs = require('node:fs'); |
| 420 | const args = process.argv.slice(2); |
| 421 | fs.appendFileSync(process.env.ROUTE_LOG, JSON.stringify({args}) + '\\n'); |
| 422 | console.log(JSON.stringify({ok:true,platform:'linux'})); |
| 423 | `); |
| 424 | const knownHosts = path.join(f.dir, "trusted-hosts"); |
| 425 | const result = await f.tool("computer_register", { |
| 426 | computer: "pad", transport: "ssh", host: "fixture.test", |
| 427 | knownHosts, installAgent: false, |
| 428 | }); |
| 429 | assert.equal(result.ok, true, JSON.stringify(result)); |
| 430 | assert.equal(result.registered.knownHosts, knownHosts); |
| 431 | assert.equal(result.registered.platformHint, "linux"); |
| 432 | assert.equal(JSON.parse(fs.readFileSync(path.join(f.dir, "computers.json"))).computers.pad.knownHosts, knownHosts); |
| 433 | const calls = f.calls(); |
| 434 | assert.equal(calls.length, 1); |
| 435 | assert.ok(calls[0].args.includes(`UserKnownHostsFile=${knownHosts}`)); |
| 436 | assert.ok(calls[0].args.includes("GlobalKnownHostsFile=/dev/null")); |
| 437 | assert.ok(calls[0].args.includes("StrictHostKeyChecking=yes")); |
| 438 | }); |
| 439 | |
| 440 | test("changing the trusted SSH host-key file changes route identity", () => { |
| 441 | const original = {transport: "ssh", host: "fixture.test", knownHosts: "/private/trusted-a"}; |
| 442 | assert.notEqual(routeFingerprint(original), routeFingerprint({...original, knownHosts: "/private/trusted-b"})); |
| 443 | assert.notEqual(routeFingerprint(original), routeFingerprint({...original, knownHosts: undefined})); |
| 444 | }); |
| 445 |