Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_pty.mjs

29.0 KiB, 1 run

created by r2519314175:607, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_pty.mjs — the terminal relay, bytes and all, against a real host
2// process.
3//
4// `www/js/handpty.js` is the page half of a pty session: it composes no fence,
5// owns no transport, and does exactly three things worth testing. It carries
6// bytes without touching them, it says so when bytes are missing, and it tells a
7// hand that STOPPED apart from one that was never installed. This drives all
8// three in a real browser.
9//
10// ── What it runs against ────────────────────────────────────────────
11//
12// `dev/mock_pty_host.py`, spawned as a real process, speaking Chrome's native
13// messaging framing and the pty half of `hand/src/wire.rs`. It is a copy of
14// `hand/install/mock_host.py` with the terminal messages added, because that
15// file belongs to whoever is teaching the hand about terminals; when the real
16// one speaks pty, this copy should go and the constant below point at it.
17//
18// The oracle for the byte path is deliberately three independent
19// implementations: Python's `base64` encodes, the browser's `atob` decodes, and
20// Node's `Buffer` compares. A payload that survives that is not surviving one
21// library's agreement with itself. The payload carries NULs, a lone
22// continuation byte, a truncated sequence, a surrogate encoding and every byte
23// value from 0 to 255, and one `output` frame boundary is placed THROUGH the
24// middle of a multi-byte character, which is exactly the case a text conversion
25// would quietly destroy.
26//
27// ── What it does NOT prove ──────────────────────────────────────────
28//
29// The link between the page and the host is a bridge in this file, not the
30// extension, for one reason: `ext/hand.js` refuses a message type it does not
31// know, and `open`, `input` and `resize` are not yet among them (see the report
32// accompanying this file). So this proves the page relay and the wire, and it
33// does not prove the extension forwards a pty message or that the hand
34// allocates a terminal. `dev/verify_hand.mjs` is where the extension's own half
35// is proved, and this file should grow the same end-to-end phase the day the
36// extension carries these messages.
37//
38// It also does not prove anything about the fence. The fence is composed by
39// `fence_spec` in `src/tools.rs`, vetted by the extension and enforced by the
40// hand; the only thing tested here is that a request arriving WITHOUT one is
41// refused rather than sent, because this relay must never be the place a weaker
42// path to the machine begins.
43//
44// Headless, and needs nothing running for the first phase. The app phase starts
45// the dev server and the mock provider itself if they are not already up —
46// without the mock provider every model turn fails and the transcript says only
47// that Daimond could not answer, which reads as a broken app rather than a
48// missing server.
49//
50// node dev/verify_pty.mjs
51import fs from 'node:fs';
52import http from 'node:http';
53import net from 'node:net';
54import os from 'node:os';
55import path from 'node:path';
56import { spawn } from 'node:child_process';
57import { fileURLToPath, pathToFileURL } from 'node:url';
58
59const PW = process.env.DAIMOND_PW
60 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
61const { chromium } = await import(pathToFileURL(PW).href);
62const CHROME = process.env.DAIMOND_CHROME
63 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
64
65const HERE = path.dirname(fileURLToPath(import.meta.url));
66const ROOT = path.join(HERE, '..');
67const RELAY = path.join(ROOT, 'www/js/handpty.js');
68const HANDJS = path.join(ROOT, 'www/js/hand.js');
69const MOCK = path.join(HERE, 'mock_pty_host.py');
70// Not /tmp -- see the SCRATCH note in harness.mjs.
71const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
72const WORK = path.join(SCRATCH, `verify-pty-${process.pid}`);
73
74const ok = [], bad = [];
75const check = (name, pass, detail) => {
76 (pass ? ok : bad).push(name);
77 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
78};
79const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
80
81fs.rmSync(WORK, { recursive: true, force: true });
82fs.mkdirSync(WORK, { recursive: true });
83
84// ── The payload ─────────────────────────────────────────────────────
85//
86// Everything a terminal actually carries and a string cannot. The offsets
87// matter: `SPLITS` cuts the euro sign in half, so the two halves of one
88// character travel in different frames and only an exact byte path puts it back
89// together.
90
91const HEAD = Buffer.from('hello ', 'ascii');
92const EURO = Buffer.from([0xe2, 0x82, 0xac]); // one character, three bytes
93const NASTY = Buffer.from([
94 0x00, 0x00, // NUL, which a C string ends at
95 0x80, 0x81, // continuation bytes with nothing to continue
96 0xc3, // a two-byte sequence that stops after one
97 0xed, 0xa0, 0x80, // a surrogate, which UTF-8 forbids outright
98 0xf0, 0x28, 0x8c, 0x28, // a four-byte sequence that is not one
99 0xff, 0xfe, // bytes that never appear in UTF-8 at all
100]);
101const ALL = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
102const PAYLOAD = Buffer.concat([HEAD, EURO, Buffer.from('\r\n'), NASTY, ALL, NASTY]);
103// Through the middle of the euro sign, and then twice more.
104const SPLITS = [HEAD.length + 1, HEAD.length + 12, HEAD.length + 120];
105const PAYFILE = path.join(WORK, 'payload.bin');
106fs.writeFileSync(PAYFILE, PAYLOAD);
107
108/// A fence a request may carry. It is NOT composed here in earnest — that is
109/// `fence_spec`'s job in src/tools.rs — it is the shape of one, so the relay
110/// can be shown passing it through untouched.
111const FENCE = { rw: [WORK], ro: [], deny: [], net: false };
112
113/// A well-formed `open`, with anything overridden and anything dropped.
114function spec(id, over, drop) {
115 const m = {
116 t: 'open', id, argv: ['bash', '-i'], cwd: WORK, env: [['TERM', 'xterm-256color']],
117 size: { cols: 80, rows: 24 }, fence: FENCE,
118 };
119 Object.assign(m, over || {});
120 for (const k of (drop || [])) delete m[k];
121 return m;
122}
123
124// ── hand.js's own sentences ─────────────────────────────────────────
125//
126// Read out of the file rather than copied, so this test cannot drift into
127// asserting a sentence the product no longer says. WHICH of them a dead link
128// produces is hand.js's decision and dev/verify_handrun.mjs proves it; what is
129// proved here is that handpty.js repeats whichever it is given, word for word,
130// and marks the two apart.
131
132/// The value of a `var NAME = '…' + '…';` in a source file, evaluated.
133function sentence(src, name) {
134 const hit = new RegExp(`var\\s+${name}\\s*=\\s*([\\s\\S]*?);\\n`).exec(src);
135 if (!hit) return '';
136 // eslint-disable-next-line no-new-func
137 try { return new Function('return ' + hit[1])(); } catch (e) { return ''; }
138}
139const HANDSRC = fs.readFileSync(HANDJS, 'utf8');
140const NO_HAND = sentence(HANDSRC, 'NO_HAND');
141const HAND_GONE = sentence(HANDSRC, 'HAND_GONE');
142
143// ── The stub page ───────────────────────────────────────────────────
144//
145// The relay under test, plus the SMALLEST thing that satisfies the interface it
146// asks of hand.js. The double is a bridge and nothing more: every message goes
147// to a real host process and every answer comes back from one. It supplies no
148// wire behaviour of its own, so nothing here can agree with handpty.js by
149// construction.
150
151const PAGE = `<!doctype html><meta charset="utf-8"><title>pty</title>
152<body><h1>pty harness</h1><script>
153// What hand.js is asked to provide. Two functions; see the report beside this file.
154window.__subs = {};
155window.__sent = [];
156window.__met = false;
157window.DaimondHand = {
158 send: function (m) {
159 window.__sent.push(m);
160 if (m && m.t !== 'hello') window.__met = true;
161 return window.__ptyOut(JSON.stringify(m));
162 },
163 subscribe: function (id, fn) {
164 (window.__subs[id] = window.__subs[id] || []).push(fn);
165 return function () {
166 var a = window.__subs[id] || [];
167 var i = a.indexOf(fn);
168 if (i >= 0) a.splice(i, 1);
169 };
170 },
171 status: function () {
172 return Promise.resolve(JSON.stringify({
173 paired: true, transport: 'machine', machine: 'mock', os: 'linux',
174 root: '/nowhere', caps: ['fence:linux', 'pty'],
175 }));
176 },
177};
178/// One message from the host, to whoever the id belongs to.
179window.__ptyIn = function (json) {
180 var m = JSON.parse(json);
181 var a = (window.__subs[m.id] || []).slice();
182 for (var i = 0; i < a.length; i++) a[i](m);
183};
184/// The link died. \`message\` and \`met\` are hand.js's to decide; this passes on
185/// whatever the test says it decided.
186window.__gone = function (message, met) {
187 for (var id in window.__subs) {
188 var a = (window.__subs[id] || []).slice();
189 for (var i = 0; i < a.length; i++) a[i]({ t: '__gone', message: message, met: met });
190 }
191};
192/// Everything one session said, recorded for the far end to compare.
193window.__watch = function (id) {
194 window.__seen = { chunks: [], types: [], gaps: [], errs: [], closed: null };
195 return {
196 onOutput: function (b) {
197 window.__seen.types.push(Object.prototype.toString.call(b));
198 window.__seen.chunks.push(Array.prototype.slice.call(b));
199 },
200 onGap: function (g) { window.__seen.gaps.push(g); },
201 onError: function (e) { window.__seen.errs.push(e); },
202 onClosed: function (c) { window.__seen.closed = c; },
203 };
204};
205<\/script>
206<script src="/handpty.js"><\/script></body>`;
207
208/// Serves the stub page, and the relay itself as the file it really is.
209async function serveStub(port) {
210 const s = http.createServer((req, res) => {
211 if (/^\/handpty\.js/.test(req.url || '')) {
212 res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
213 res.end(fs.readFileSync(RELAY));
214 return;
215 }
216 res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
217 res.end(PAGE);
218 });
219 await new Promise((resolve, reject) => { s.once('error', reject); s.listen(port, '127.0.0.1', resolve); });
220 return s;
221}
222
223/// The first free port from `from`, so a dev server already holding one is left
224/// alone rather than fought over.
225async function stubOnFreePort(from) {
226 for (let port = from; port < from + 40; port++) {
227 try { return { s: await serveStub(port), port }; }
228 catch (e) { if (e.code !== 'EADDRINUSE') throw e; }
229 }
230 throw new Error(`No free port from ${from}.`);
231}
232
233// ── The bridge ──────────────────────────────────────────────────────
234
235let host = null; // the mock host process, or null
236let hostLog = ''; // where it is writing what it saw
237let deliver = Promise.resolve(); // host -> page, strictly in order
238
239/// Starts a fresh host with the behaviour this case needs.
240///
241/// One process per case, because the host reads its configuration when it
242/// starts — and because a session that outlived its case would answer the next
243/// one.
244function startHost(cfg, page) {
245 stopHost();
246 const n = Math.random().toString(36).slice(2, 8);
247 const cf = path.join(WORK, `cfg-${n}.json`);
248 hostLog = path.join(WORK, `log-${n}.jsonl`);
249 fs.writeFileSync(cf, JSON.stringify(Object.assign({ payload: PAYFILE, splits: SPLITS }, cfg), null, '\t'));
250 const p = spawn('python3', [MOCK, cf, hostLog], { stdio: ['pipe', 'pipe', 'inherit'] });
251 let buf = Buffer.alloc(0);
252 p.stdout.on('data', (d) => {
253 buf = Buffer.concat([buf, d]);
254 for (;;) {
255 if (buf.length < 4) break;
256 // Native byte order, which on every machine this runs on is little.
257 const n2 = buf.readUInt32LE(0);
258 if (buf.length < 4 + n2) break;
259 const body = buf.subarray(4, 4 + n2).toString('utf8');
260 buf = buf.subarray(4 + n2);
261 deliver = deliver.then(() => page.evaluate((j) => window.__ptyIn(j), body).catch(() => {}));
262 }
263 });
264 host = p;
265 return p;
266}
267
268/// Stops it, so the next case gets a fresh one.
269function stopHost() {
270 if (!host) return;
271 try { host.kill('SIGKILL'); } catch (e) { /* already gone */ }
272 host = null;
273}
274
275/// Everything the host recorded, as objects.
276function heard() {
277 if (!hostLog || !fs.existsSync(hostLog)) return [];
278 return fs.readFileSync(hostLog, 'utf8').split('\n').filter(Boolean)
279 .map((l) => { try { return JSON.parse(l); } catch (e) { return null; } }).filter(Boolean);
280}
281
282/// What the host was SENT of a given type, which is the honest way to ask
283/// whether a message travelled.
284function received(t) {
285 return heard().filter((e) => e.dir === '<-' && e.msg && e.msg.t === t).map((e) => e.msg);
286}
287
288/// Everything the page has been given about the session under watch, once the
289/// host has finished talking.
290async function seen(page) {
291 await deliver;
292 await sleep(120);
293 await deliver;
294 return page.evaluate(() => window.__seen);
295}
296
297/// The bytes a subscriber was handed, joined.
298function bytes(s) {
299 return Buffer.concat((s.chunks || []).map((c) => Buffer.from(c)));
300}
301
302// ── The two servers, for the app phase ──────────────────────────────
303
304function listening(port) {
305 return new Promise((resolve) => {
306 const s = net.connect(port, '127.0.0.1');
307 s.once('connect', () => { s.destroy(); resolve(true); });
308 s.once('error', () => resolve(false));
309 });
310}
311const started = [];
312async function serve(name, args, port) {
313 if (await listening(port)) { console.log(` (${name} already up on ${port})`); return; }
314 const p = spawn('node', args, { cwd: ROOT, stdio: 'ignore' });
315 started.push(p);
316 for (let i = 0; i < 100; i++) {
317 if (await listening(port)) { console.log(` (started ${name} on ${port})`); return; }
318 await sleep(100);
319 }
320 throw new Error(`${name} did not come up on ${port}`);
321}
322
323// ── Phase one: the relay, the wire, and a real host ─────────────────
324
325const stub = await stubOnFreePort(8977);
326const b = await chromium.launchPersistentContext(path.join(WORK, 'profile'), {
327 executablePath: CHROME,
328 headless: false,
329 args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'],
330 viewport: { width: 1000, height: 700 },
331});
332const page = b.pages()[0] || await b.newPage();
333const errs = [];
334page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()); });
335page.on('pageerror', (e) => errs.push(`pageerror: ${e.message}`));
336await page.exposeFunction('__ptyOut', (json) => {
337 if (!host) return;
338 const data = Buffer.from(json, 'utf8');
339 const head = Buffer.alloc(4);
340 head.writeUInt32LE(data.length, 0);
341 try { host.stdin.write(Buffer.concat([head, data])); } catch (e) { /* it has gone */ }
342});
343await page.goto(`http://127.0.0.1:${stub.port}/`, { waitUntil: 'domcontentloaded' });
344
345try {
346 check('the payload is the awkward one, or the test proves nothing',
347 PAYLOAD.includes(0x00) && PAYLOAD.includes(0x80) && PAYLOAD.includes(0xff)
348 && !Buffer.from(PAYLOAD.toString('utf8'), 'utf8').equals(PAYLOAD),
349 `${PAYLOAD.length} bytes, and a text round trip does not survive it`);
350 check('the relay loaded and installed itself',
351 await page.evaluate(() => !!(window.DaimondPty && window.DaimondPty.open)));
352
353 // ── A session opens, and its id comes back ──────────────────────
354 startHost({}, page);
355 let got = await page.evaluate(async (sp) => {
356 const h = window.__watch(sp.id);
357 return await window.DaimondPty.open(sp, h).then((v) => ({ ok: v }), (e) => ({ err: e.message }));
358 }, spec('t1'));
359 check('a terminal opens and resolves with its id', !!got.ok && got.ok.id === 't1', JSON.stringify(got));
360 check('and with the process it is attached to', !!got.ok && got.ok.pid > 0, JSON.stringify(got.ok));
361
362 // ── The bytes are the bytes ─────────────────────────────────────
363 let s = await seen(page);
364 const back = bytes(s);
365 check('the subscriber is handed raw bytes, never base64',
366 s.types.length > 0 && s.types.every((t) => t === '[object Uint8Array]'), s.types.join(' '));
367 check('the output is byte-for-byte what the program wrote',
368 back.equals(PAYLOAD), `${back.length} of ${PAYLOAD.length} bytes`);
369 check('including the NULs and the invalid UTF-8',
370 back.includes(0x00) && back.includes(0x80) && back.includes(0xed) && back.includes(0xff),
371 back.slice(0, 24).toString('hex'));
372 check('a character cut in half by a frame boundary is put back together',
373 received('open').length === 1 && back.indexOf(EURO) === HEAD.length,
374 `euro at ${back.indexOf(EURO)}, expected ${HEAD.length}`);
375 check('it really did arrive in more than one frame',
376 heard().filter((e) => e.dir === '->' && e.msg.t === 'output').length >= SPLITS.length + 1,
377 String(heard().filter((e) => e.dir === '->' && e.msg.t === 'output').length));
378 check('nothing was reported missing when nothing was', s.gaps.length === 0, JSON.stringify(s.gaps));
379
380 // ── Typing travels, exactly ─────────────────────────────────────
381 const typed = Buffer.from([0x03, 0x1b, 0x5b, 0x41, 0x00, 0xff, 0xc3, 0xa9, 0x0d]);
382 await page.evaluate(async ({ id, t }) => {
383 await window.DaimondPty.input(id, new Uint8Array(t));
384 }, { id: 't1', t: Array.from(typed) });
385 await sleep(300);
386 const ins = received('input');
387 check('keystrokes travel as one input message', ins.length === 1, JSON.stringify(ins).slice(0, 120));
388 check('and carry exactly the bytes typed, Ctrl-C, NUL and all',
389 ins.length === 1 && Buffer.from(ins[0].data, 'base64').equals(typed),
390 ins.length ? Buffer.from(ins[0].data, 'base64').toString('hex') : '');
391 s = await seen(page);
392 check('and what the terminal echoes comes back as those same bytes',
393 bytes(s).equals(Buffer.concat([PAYLOAD, typed])), `${bytes(s).length} bytes`);
394
395 // ── A resize travels ────────────────────────────────────────────
396 await page.evaluate(() => window.DaimondPty.resize('t1', 132, 43));
397 await sleep(250);
398 const rs = received('resize');
399 check('a resize travels, with the new size',
400 rs.length === 1 && rs[0].size.cols === 132 && rs[0].size.rows === 43, JSON.stringify(rs));
401
402 // ── Asking it to stop ───────────────────────────────────────────
403 await page.evaluate(() => window.DaimondPty.close('t1'));
404 await sleep(300);
405 const sig = received('signal');
406 s = await seen(page);
407 check('closing asks the program to stop rather than insisting',
408 sig.length === 1 && sig[0].sig === 'term', JSON.stringify(sig));
409 check('and the session ends with the status the hand reported',
410 !!s.closed && s.closed.exit === 0, JSON.stringify(s.closed));
411 check('a closed session is no longer live in the page',
412 (await page.evaluate(() => window.DaimondPty.sessions())).length === 0);
413
414 // ── A hole is surfaced, not stitched ────────────────────────────
415 startHost({ gap: true }, page);
416 got = await page.evaluate(async (sp) => {
417 const h = window.__watch(sp.id);
418 return await window.DaimondPty.open(sp, h).then((v) => ({ ok: v }), (e) => ({ err: e.message }));
419 }, spec('t2'));
420 s = await seen(page);
421 check('a hole in the sequence is reported', s.gaps.length === 1, JSON.stringify(s.gaps));
422 check('and it says which chunk is missing, and how many',
423 s.gaps.length === 1 && s.gaps[0].missing === 1 && s.gaps[0].got === s.gaps[0].expected + 1,
424 JSON.stringify(s.gaps[0]));
425 check('the bytes still go through, hole and all — nothing is invented and nothing dropped',
426 bytes(s).equals(PAYLOAD), `${bytes(s).length} of ${PAYLOAD.length}`);
427 check('and the marker is beside the stream, not written into it',
428 !bytes(s).includes(Buffer.from('missing')), '');
429 await page.evaluate(() => window.DaimondPty.forget('t2'));
430
431 // ── A host that dies mid-session ────────────────────────────────
432 //
433 // The host really exits; the bridge sees the pipe close, exactly as the
434 // extension would. WHICH sentence a dead link produces is hand.js's
435 // decision and dev/verify_handrun.mjs proves it. What is proved here is
436 // that a terminal repeats it word for word and marks a hand that STOPPED
437 // apart from one that was never there.
438 startHost({ crash_after_ms: 400 }, page);
439 got = await page.evaluate(async (sp) => {
440 const h = window.__watch(sp.id);
441 return await window.DaimondPty.open(sp, h).then((v) => ({ ok: v }), (e) => ({ err: e.message }));
442 }, spec('t3'));
443 check('the session was open before the host died', !!got.ok, JSON.stringify(got));
444 await new Promise((r) => { if (!host || host.exitCode !== null) r(); else host.once('exit', r); });
445 await page.evaluate((m) => window.__gone(m, true), HAND_GONE);
446 s = await seen(page);
447 check('a host that dies mid-session ends it rather than leaving it hanging',
448 !!s.closed && s.closed.exit === -1 && s.closed.killed === true, JSON.stringify(s.closed));
449 check('it is reported as a hand that STOPPED, not one that is absent',
450 !!s.closed && s.closed.stopped === true && s.closed.absent === false, JSON.stringify(s.closed));
451 check('and the sentence is hand.js\'s own, word for word',
452 !!s.closed && s.closed.reason === HAND_GONE && HAND_GONE.length > 40,
453 (s.closed || {}).reason);
454 // HAND_GONE now says the hand "is installed and does not need installing
455 // again" -- it used to instruct a daimon not to tell the user to install it,
456 // which was the same fact addressed to the wrong reader, and the Terminal
457 // panel shows this sentence to the user. Either way the question that matters
458 // is unchanged: it is not the never-installed sentence, and it does not say
459 // the hand is not installed.
460 check('so it does NOT tell the user to install what they have already installed',
461 !!s.closed && s.closed.reason !== NO_HAND && !/is not installed/i.test(s.closed.reason || ''),
462 (s.closed || {}).reason);
463
464 // And the other half of the same distinction, with the link dying while the
465 // opening is still in flight — nothing answers at all, which is what a hand
466 // that was never installed looks like from here.
467 stopHost();
468 await page.evaluate(async (sp) => {
469 const h = window.__watch(sp.id);
470 window.__t4 = window.DaimondPty.open(sp, h).then((v) => ({ ok: v }), (e) => ({ err: e.message }));
471 }, spec('t4'));
472 await sleep(300);
473 await page.evaluate((m) => window.__gone(m, false), NO_HAND);
474 s = await seen(page);
475 const t4 = await page.evaluate(() => window.__t4);
476 check('a link that never met a hand is reported as absent instead',
477 !!s.closed && s.closed.absent === true && s.closed.stopped === false, JSON.stringify(s.closed));
478 check('and a caller still waiting on the opening is given that sentence, not a hang',
479 !!t4.err && t4.err === NO_HAND, JSON.stringify(t4).slice(0, 200));
480
481 // ── The fence is not this file's to invent ──────────────────────
482 startHost({}, page);
483 const nofence = await page.evaluate(async (sp) =>
484 await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message })),
485 spec('t5', {}, ['fence']));
486 const norootFence = await page.evaluate(async (sp) =>
487 await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message })),
488 spec('t6', { fence: { rw: [], ro: [], deny: [], net: true } }));
489 const relcwd = await page.evaluate(async (sp) =>
490 await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message })),
491 spec('t7', { cwd: 'work' }));
492 check('a terminal with no fence is refused, naming where one comes from',
493 !!nofence.err && /fence_spec/.test(nofence.err), (nofence.err || '').slice(0, 120));
494 check('and one whose fence names no root at all',
495 !!norootFence.err && /no root/.test(norootFence.err), (norootFence.err || '').slice(0, 120));
496 check('and one with a working directory that is not absolute',
497 !!relcwd.err && /absolute/.test(relcwd.err), (relcwd.err || '').slice(0, 120));
498 check('none of them reached the host',
499 received('open').length === 0, JSON.stringify(received('open')).slice(0, 120));
500
501 // ── A page whose relay cannot carry terminals ───────────────────
502 const noCarry = await page.evaluate(async (sp) => {
503 const real = window.DaimondHand;
504 window.DaimondHand = { status: real.status }; // an older hand.js: no send, no subscribe
505 const r = await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message }));
506 const st = JSON.parse(await window.DaimondPty.status());
507 window.DaimondHand = real;
508 return { r, st };
509 }, spec('t8'));
510 check('a page whose relay predates terminals says so, and blames the page',
511 !!noCarry.r.err && /Reload the app/.test(noCarry.r.err), (noCarry.r.err || '').slice(0, 140));
512 check('and does not send the user off to reinstall a working hand',
513 !!noCarry.r.err && !/install/i.test(noCarry.r.err), (noCarry.r.err || '').slice(0, 140));
514 check('status says the page cannot carry them, without pretending to be a hand',
515 noCarry.st.carries === false && !!noCarry.st.reason, JSON.stringify(noCarry.st).slice(0, 160));
516
517 const noRelay = await page.evaluate(async (sp) => {
518 const real = window.DaimondHand;
519 delete window.DaimondHand;
520 const r = await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message }));
521 window.DaimondHand = real;
522 return r;
523 }, spec('t9'));
524 check('and a page with no hand relay at all is a fault in the app, said as one',
525 !!noRelay.err && /js\/hand\.js/.test(noRelay.err), (noRelay.err || '').slice(0, 140));
526
527 // ── Output that arrives before anyone is drawing ────────────────
528 //
529 // A program writes its first screen at once, and a terminal that missed its
530 // own first screen is broken. Opened with no handlers, subscribed to
531 // afterwards, and everything must still be there.
532 startHost({}, page);
533 const late = await page.evaluate(async (sp) => {
534 const r = await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message }));
535 return r;
536 }, spec('ta'));
537 await sleep(400);
538 await page.evaluate((id) => { window.DaimondPty.subscribe(id, window.__watch(id)); }, 'ta');
539 s = await seen(page);
540 check('output that arrived before a renderer attached is still delivered',
541 !!late.ok && bytes(s).equals(PAYLOAD), `${bytes(s).length} of ${PAYLOAD.length}`);
542 await page.evaluate(() => window.DaimondPty.forget('ta'));
543
544 check('the page threw nothing along the way', errs.length === 0, errs.slice(0, 3).join(' | '));
545} finally {
546 stopHost();
547 await b.close().catch(() => {});
548 stub.s.close();
549}
550
551// ── Phase two: the file in the real app ─────────────────────────────
552//
553// The relay is a page script and has to behave inside the page it ships in: it
554// must load beside the rest, and it must degrade into a sentence rather than an
555// exception when the machine hand is not there — which, on a plain dev profile
556// with no extension, it is not.
557
558// What the children will bind: `serve.mjs` reads DAIMOND_PORT and `mockllm.mjs`
559// DAIMOND_MOCK_PORT, so the wait below is asking about the port they chose.
560const APP_PORT = Number(process.env.DAIMOND_PORT || 8777);
561const MOCK_PORT = Number(process.env.DAIMOND_MOCK_PORT || 9099);
562await serve('dev server', ['dev/serve.mjs'], APP_PORT);
563await serve('mock provider', ['dev/mockllm.mjs'], MOCK_PORT);
564
565const { open: openApp } = await import(pathToFileURL(path.join(HERE, 'harness.mjs')).href);
566const app = await openApp({ name: 'pty', connect: false });
567try {
568 await app.page.addScriptTag({ path: RELAY });
569 await sleep(200);
570 const st = JSON.parse(await app.page.evaluate(() => window.DaimondPty.status()));
571 check('the relay loads into the real app and answers about itself',
572 typeof st.carries === 'boolean' && st.sessions === 0, JSON.stringify(st).slice(0, 200));
573 const r = await app.page.evaluate(async (sp) =>
574 await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message })),
575 { t: 'open', id: 'app1', argv: ['bash'], cwd: '/nowhere', env: [], size: { cols: 80, rows: 24 },
576 fence: { rw: ['/nowhere'], ro: [], deny: [], net: false } });
577 // The sentence is the LINK's, not this relay's, and that is the change worth
578 // noting: hand.js now carries terminal messages, so an `open` on a machine
579 // with no hand gets as far as the link and comes back with the sentence
580 // hand.js already writes for a hand that is not installed -- verbatim, which
581 // is what handpty.js's header asks of it. Before hand.js grew `send` and
582 // `subscribe` this refusal was NO_CARRY, which is a different fault with a
583 // different instruction: reload the app, rather than install the hand.
584 check('and with no hand on this machine it refuses in a whole sentence, not an exception',
585 !!r.err && r.err.length > 60 && /machine hand|terminal/i.test(r.err),
586 (r.err || '').slice(0, 160));
587 const noise = app.errs.filter((e) => !/favicon|ERR_ABORTED|502|Bad Gateway/i.test(e));
588 check('the app threw nothing when it loaded', noise.length === 0, noise.slice(0, 3).join(' | '));
589} finally {
590 await app.close().catch(() => {});
591 for (const p of started) { try { p.kill(); } catch (e) { /* already gone */ } }
592 fs.rmSync(WORK, { recursive: true, force: true });
593}
594
595console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
596process.exit(bad.length ? 1 : 0);