返回 CodeWhale
server-routes.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / server-routes.test.mjs
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
445 lines Plain Text