Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_ptyedge.mjs

80.0 KiB, 1 run

created by r2519314175:609, 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_ptyedge.mjs — the three rungs under the Terminal panel, proved on the
2// real thing.
3//
4// The panel above these was built and verified first, against a relay that
5// could not carry a terminal message and a wasm module that exported nothing to
6// compose one. This file is the other end: it proves that hand.js multiplexes a
7// terminal onto the ONE link it already holds, that `Wasm.pty_request` composes
8// a request the real hand accepts, and that the screen model survives a resize
9// the way a real terminal does.
10//
11// ── What it runs against ────────────────────────────────────────────
12//
13// The REAL extension (the dev build, which is the shipped one plus the
14// loopback origins) and the REAL `hand/target/release/daimond-hand`, registered
15// as this profile's native messaging host and told which folder it was granted.
16// So a session opened here goes page → hand.js → the extension's vetting → the
17// hand → a pseudo-terminal on this machine, with nothing mocked anywhere along
18// it. `test -t 0` is answered by the kernel and `stty size` by the terminal
19// driver; neither can be satisfied by a test that is agreeing with itself.
20//
21// ── One thing is stood in for, and it is named ──────────────────────
22//
23// `hand/REVIEW.md` §1.14: the relay refuses to pair where the folder the page
24// has open cannot be shown to be the folder the hand was granted. This page has
25// no folder — and no automated page can have one, because a real folder arrives
26// only through `showDirectoryPicker()`, a native dialog no harness can answer —
27// so the verdict here is always a refusal. Section 3 asserts that refusal
28// against the real hand; everywhere else `status()` is wrapped so that the
29// verdict, and only the verdict, is stood in for. The hand's own account of
30// itself passes through untouched, and every fence below is composed from it.
31//
32// ── The oracle for the screen model is tmux ─────────────────────────
33//
34// A resize is asserted against what tmux 3.6 actually does with the same
35// sequence, read back with `capture-pane`, rather than against what this file
36// thinks a terminal should do. The cases are the two that matter: the cursor
37// inside the new height (where a shrink must keep the text and cost nothing)
38// and the cursor below it (where the top of the screen goes into the history
39// and comes back on the way out).
40//
41// ── Proved against broken code ──────────────────────────────────────
42//
43// Every property here is proved twice: the code under test is BROKEN and the
44// check is required to go red, then restored and required to go green. The
45// break is in the file or the wasm being tested, never in the harness — a
46// harness that breaks itself proves only that both worlds answer alike.
47//
48// hand.js and terminal.js served through a patch, and the page reloaded.
49// pty_request a whole second wasm package, built from a
50// patched src/wasm/pty.rs or src/tools.rs. Slow,
51// and the only honest way to break a function
52// that lives in a .wasm.
53//
54// node dev/verify_ptyedge.mjs # reuse the packages built from THIS
55// # source; rebuild any that are older
56// node dev/verify_ptyedge.mjs --prove # rebuild them all (about three minutes)
57//
58// Needs tmux, python3 and a built www/pkg. Run it headed, under xvfb, because
59// the grant window is a real window and is really clicked:
60// xvfb-run -a -s "-screen 0 1400x900x24" node dev/verify_ptyedge.mjs
61//
62// `xvfb-run` alone was NOT enough here until 2026-08-24, and this line said it
63// was. It sets `DISPLAY` and nothing else; Chromium picks its ozone platform by
64// autodetection and prefers Wayland whenever `WAYLAND_DISPLAY` is set, which it
65// is in every rc session on this machine -- so this file connected to the seat's
66// compositor and opened a real window on the owner's desktop while he was
67// working. `dev/display.mjs`, imported below, takes both variables out, so the
68// command above is now true as written.
69import fs from 'node:fs';
70import http from 'node:http';
71import os from 'node:os';
72import path from 'node:path';
73import { execFileSync, spawnSync } from 'node:child_process';
74import { fileURLToPath, pathToFileURL } from 'node:url';
75// Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever
76// `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed
77// run under `xvfb-run` still went to the compositor and opened a window on the owner's
78// desktop, for two and a half minutes on 2026-08-24 while he was working. Importing this
79// strips the two variables from `process.env`, which is all a launcher that spreads
80// `process.env` into its options needs. See dev/display.mjs.
81import './display.mjs';
82import { whyStaleWasm, refuse } from './staleguard.mjs';
83
84const PW = process.env.DAIMOND_PW
85 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
86const { chromium } = await import(pathToFileURL(PW).href);
87const CHROME = process.env.DAIMOND_CHROME
88 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
89
90const HERE = path.dirname(fileURLToPath(import.meta.url));
91const ROOT = path.join(HERE, '..');
92const WWW = path.join(ROOT, 'www');
93const HAND = path.join(ROOT, 'hand/target/release/daimond-hand');
94// Not /tmp -- it is a tmpfs and what is written there is RAM charged to this
95// machine's agent fleet. See the SCRATCH note in harness.mjs.
96const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
97const WORK = path.join(SCRATCH, `ptyedge-${process.pid}`);
98const PROFILE = path.join(WORK, 'profile');
99const BROKEN = path.join(SCRATCH, 'ptyedge-broken');
100const PROVE = process.argv.includes('--prove');
101
102// Where the wasm packages below are compiled, and it is PRIVATE TO THIS SLOT.
103//
104// It used to be `~/.cache/cargo-targets/daimond_ptyedge_target`, one directory
105// for the whole fleet. The isolation unit is the (slot, workspace root) pair:
106// two agents building the same workspace into one target directory write two
107// differently resolved artefacts of the same crate and the link takes a mix of
108// them. That is not theoretical here — on 2026-08-24 it gave a lane five
109// failures in this file which all went green, 61 ok / 0 failed, once the build
110// went into a slot-private directory instead. The tell is rustc's "there are
111// multiple different versions of crate X in the dependency graph" while
112// `cargo tree -d` shows one: the duplicate is in the target directory, not the
113// manifest.
114//
115// An inherited `CARGO_TARGET_DIR` is deliberately NOT used. The build below
116// carries its own `--remap-path-prefix` flags, so sharing a directory with an
117// ordinary `cargo build` costs a full recompile in each direction every time,
118// and the whole point of naming the slot is that the path is predictable.
119// `PTYEDGE_TARGET_DIR` overrides it for anyone who needs to.
120const TARGET_DIR = process.env.PTYEDGE_TARGET_DIR
121 || path.join(os.homedir(), '.cache/cargo-targets', process.env.RC_SLOT || 'solo',
122 'daimond_ptyedge');
123
124// The Diamond this session belongs to, as the panel names it. NOT a directory on
125// this machine: it is Daimond's own storage, in the browser, whatever folder the
126// user has open — so the fence never maps it onto the disk and nothing here
127// creates it.
128const OWN_DIR = 'diamonds/d1';
129
130// The folder the user ATTACHED to that Diamond, which is the only place on this
131// machine a Diamond has. Everything a session does happens here: the fence's one
132// writable root, and where a terminal asked for in the Diamond ends up starting.
133//
134// It is new, and its absence is what this file was missing. The old fixture made
135// `<root>/diamonds/d1` itself, so the fence resolved and the hand was happy —
136// in a world nothing but this file ever built. In the field that directory is
137// never there, the hand could not canonicalise it, and it refused the whole
138// spec: every `run` in every chat and every Diamond terminal, while these
139// checks stayed green.
140const WORK_DIR = 'work';
141
142const ok = [], bad = [];
143const check = (name, pass, detail) => {
144 (pass ? ok : bad).push(name);
145 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
146};
147const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
148
149/// A property proved twice: broken, and required to fail; whole, and required
150/// to pass. `proved` counts only the pairs where BOTH halves answered as they
151/// should — a check that passes with the code broken is blind and is reported
152/// as such rather than counted.
153const provedNames = [];
154async function proved(name, breakIt, testIt, fixIt) {
155 await breakIt();
156 let red = true;
157 try { red = await testIt(); } catch (e) { red = false; }
158 await fixIt();
159 let green = false;
160 try { green = await testIt(); } catch (e) { green = false; }
161 provedNames.push(name);
162 check(`PROVED ${name}`, !red && green,
163 `broken=${red ? 'PASSED — the check is blind' : 'failed, correctly'}, `
164 + `whole=${green ? 'passed' : 'FAILED'}`);
165}
166
167// The granted folder, with the two directories a user would have made in it: one
168// attached to this Diamond and one attached read-only. `<root>/diamonds/d1` is
169// NOT made, and must not be: a Diamond's own directory lives in the browser's
170// storage whatever folder is open, so nothing in the field creates it, and a
171// fixture that did would let a fence naming it resolve here and nowhere else.
172// That is exactly how this file stayed green while every Diamond terminal was
173// being refused (see WORK_DIR).
174fs.rmSync(WORK, { recursive: true, force: true });
175fs.mkdirSync(path.join(WORK, 'root', WORK_DIR), { recursive: true });
176fs.mkdirSync(path.join(WORK, 'root/.daimond'), { recursive: true });
177fs.mkdirSync(path.join(WORK, 'root/shared'), { recursive: true });
178const GRANT = fs.realpathSync(path.join(WORK, 'root'));
179fs.writeFileSync(path.join(GRANT, WORK_DIR, 'hello.txt'), 'inside the fence\n');
180fs.writeFileSync(path.join(GRANT, '.daimond/secret.txt'), 'out of bounds\n');
181
182// ┌───────────────────────────────────────────────────────────────┐
183// │ The hand under test is built here, now, from this tree │
184// └───────────────────────────────────────────────────────────────┘
185//
186// This file used to take whatever was lying in `hand/target/release` and, where
187// there was nothing, print a line and leave. Neither half is safe. Nothing in
188// the test suite builds that binary, and nor did this file, so a release binary
189// from last week would drive the pty edge and report it green against code that
190// no longer exists — a verifier that cannot say which code it measured must not
191// report success. `verify_kitfence.mjs` records having measured exactly that:
192// with a `CARGO_TARGET_DIR` inherited, its checks passed against a binary from
193// before the clamp existed. `hand/src/exec.rs`'s `shipping_hand` refuses for the
194// same reason on the debug side.
195//
196// So: build first, and then require the artefact to be newer than every source
197// cargo says went into it. `CARGO_TARGET_DIR` is REMOVED from the build's
198// environment, and that is not tidying — an agent working in this tree usually
199// has one set, cargo would write the new binary there, and `HAND`, the path this
200// registers as the profile's native messaging host, would still be whatever was
201// built last.
202
203/// What to run by hand when this refuses.
204const REBUILD = 'cargo build --release --manifest-path hand/Cargo.toml';
205
206/// The prerequisites named on one line of a cargo dep-info file.
207///
208/// Make's escaping, which is what the format is: a backslash makes the character
209/// after it ordinary, so a path containing a space survives being split on
210/// whitespace. The same reading as `dep_sources` in `hand/src/exec.rs`.
211function depSources(list) {
212 const out = [];
213 let cur = '', esc = false;
214 for (const c of list) {
215 if (esc) { cur += c; esc = false; }
216 else if (c === '\\') { esc = true; }
217 else if (/\s/.test(c)) { if (cur) { out.push(cur); cur = ''; } }
218 else { cur += c; }
219 }
220 if (cur) out.push(cur);
221 return out;
222}
223
224/// Why the binary at `bin` is not this tree's, or '' when there is no reason.
225///
226/// The oracle is cargo's own dep-info file, `<bin>.d`, written beside it: it
227/// names every source that went into the binary, this crate's and every fe2o3
228/// crate's, so a change anywhere below the pty edge counts. A missing binary, a
229/// missing or silent record, a source that can no longer be read, or a source
230/// newer than the binary are each a refusal with the sentence that says so.
231function whyStale(bin) {
232 let built;
233 try { built = fs.statSync(bin).mtimeMs; }
234 catch (e) {
235 return `The pty edge cannot be verified, because there is no hand at ${bin} `
236 + `to verify it against. Run \`${REBUILD}\` and try again.`;
237 }
238 const record = `${bin}.d`;
239 let listed;
240 try { listed = fs.readFileSync(record, 'utf8'); }
241 catch (e) {
242 return `The pty edge cannot be verified, because ${bin} has no dep-info file at `
243 + `${record}, so there is no record of what went into it and its vintage `
244 + `cannot be established. Run \`${REBUILD}\` and try again.`;
245 }
246 let described = false;
247 let newest = null;
248 for (const line of listed.split('\n')) {
249 const at = line.indexOf(':');
250 if (at < 0) continue;
251 if (path.resolve(line.slice(0, at).trim()) !== path.resolve(bin)) continue;
252 described = true;
253 for (const src of depSources(line.slice(at + 1))) {
254 let t;
255 try { t = fs.statSync(src).mtimeMs; }
256 catch (e) {
257 return `The pty edge cannot be verified, because ${bin} was built from `
258 + `${src}, which can no longer be read, so what is inside the binary `
259 + `cannot be established. Run \`${REBUILD}\` and try again.`;
260 }
261 if (!newest || t > newest.t) newest = { src, t };
262 }
263 }
264 if (!described) {
265 return `The pty edge cannot be verified, because ${record} says nothing about `
266 + `${bin}, so that binary is not the one this build produced. Run `
267 + `\`${REBUILD}\` and try again.`;
268 }
269 if (newest && newest.t > built) {
270 const by = Math.round((newest.t - built) / 1000);
271 return `The pty edge would have been verified against a stale hand, which proves `
272 + `nothing about it in either direction: ${newest.src} was last changed ${by} `
273 + `second(s) after ${bin} was linked, so that binary is not this source. Run `
274 + `\`${REBUILD}\` and try again.`;
275 }
276 return '';
277}
278
279const buildEnv = { ...process.env };
280delete buildEnv.CARGO_TARGET_DIR;
281// `PTYEDGE_NO_BUILD` skips the build and nothing else: the staleness guard below
282// runs either way, so the variable cannot make this file report success against
283// code it did not test — it can only make it refuse. It is here so that the
284// guard can be shown to refuse, which is the only way to know it works.
285if (!process.env.PTYEDGE_NO_BUILD) {
286 const built = spawnSync('cargo', ['build', '--release', '--manifest-path', 'hand/Cargo.toml'],
287 { cwd: ROOT, encoding: 'utf8', env: buildEnv });
288 if (built.status !== 0) {
289 console.error(`The hand did not build, so there is nothing here to verify the pty `
290 + `edge against. Fix the build and run this again.\n`
291 + (built.stderr || '').split('\n').filter((l) => /^error/.test(l)).slice(0, 5).join('\n'));
292 process.exit(2);
293 }
294}
295const stale = whyStale(HAND);
296if (stale) {
297 console.error(stale);
298 process.exit(2);
299}
300console.log(`The hand under test was built from this tree: ${HAND}`);
301
302// ── And the other half of what is under test ────────────────────
303//
304// Sections 1 to 3 run against `www/pkg`, so `pty_request` there is as much the
305// code under test as the hand is, and a bundle older than the engine it was
306// built from would carry the same fail-open: green against a composition that no
307// longer exists. Every `.rs` under `src/` goes into that bundle, so any one of
308// them being newer than it is enough to say it is not this tree. Not rebuilt
309// here, because a wasm build is minutes and a surprise one in the middle of a
310// verifier is worse than a sentence saying what to run.
311//
312// ASKED OF `dev/staleguard.mjs`, WHICH IS WHERE THIS QUESTION LIVES NOW. What stood here was a
313// private copy of the guard that could only read the clock, and a clock is the wrong oracle in
314// a tree the bundle was not built in. `dev/gate.sh` checks a commit out into a frozen worktree
315// and builds or borrows a bundle for it; a later verifier then patches a `.rs` file and puts it
316// back, which leaves the CONTENT identical and the MTIME newer than the bundle. On the gate of
317// 2026-08-25 that was `src/tools.rs`, stamped 1,364 seconds after a bundle built from it, with
318// the tree still clean to `git status` -- and this file refused with its 2,400-second budget
319// untouched while `verify_handrun`, asking the shared guard the same question about the same
320// bundle, ran and passed. staleguard.mjs's own header names this file as one of the three it
321// was written for; it was the one that never started asking.
322//
323// The shared guard prefers the RECORD `gate.sh` writes beside the bundle -- every source file
324// rehashed against what the record names -- and falls back to the clock only where there is no
325// record, which is the case the clock is right about.
326const WASM_FILE = path.join(WWW, 'pkg/oxedyne_daimond_bg.wasm');
327refuse(whyStaleWasm(WASM_FILE, path.join(ROOT, 'src'), {
328 subject: 'The pty edge',
329 holds: '`pty_request`',
330}));
331console.log(`and the engine under test was built from this tree: ${WASM_FILE}`);
332
333/// Every Rust source under `dir`, which is everything the bundle is built from.
334///
335/// Kept for the cached PROVED packages further down, which are asked a different question from
336/// the one above: not "is this bundle this source" but "is this package older than this source".
337function rustSources(dir) {
338 const out = [];
339 for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
340 const f = path.join(dir, ent.name);
341 if (ent.isDirectory()) out.push(...rustSources(f));
342 else if (ent.name.endsWith('.rs')) out.push(f);
343 }
344 return out;
345}
346
347const newestSrc = rustSources(path.join(ROOT, 'src'))
348 .map((f) => ({ f, t: fs.statSync(f).mtimeMs }))
349 .reduce((a, b) => (a && a.t >= b.t ? a : b), null);
350
351// ┌───────────────────────────────────────────────────────────────┐
352// │ The tmux oracle │
353// └───────────────────────────────────────────────────────────────┘
354//
355// A real terminal, driven with the same bytes, read back with the tool that
356// ships with it. The pane runs `cat` on a fifo so that nothing but the lines
357// under test is ever written: a shell would add a prompt, and typing into one
358// would add the echo of the keystrokes, which is how a test ends up asserting
359// against its own input.
360
361/// One resize, as tmux does it.
362///
363/// # Arguments
364/// * `rows` - The height the pane opens at.
365/// * `lines` - The lines printed before the resize.
366/// * `nr` - The height it is resized to.
367/// * `back` - An optional second height, to grow back to.
368///
369/// # Returns
370/// `{ screen, cursorY, hist, history }` for each stage, or null when tmux is
371/// not on this machine.
372function tmuxResize(rows, lines, nr, back) {
373 const sock = `ptyedge${process.pid}`;
374 const fifo = path.join(WORK, `fifo-${Math.random().toString(36).slice(2, 8)}`);
375 const tm = (...a) => execFileSync('tmux', ['-L', sock, '-f', '/dev/null', ...a],
376 { encoding: 'utf8' });
377 execFileSync('mkfifo', [fifo]);
378 // Held open from this end for as long as the case lasts, so `cat` does not
379 // see end-of-file after the first write and exit.
380 const fd = fs.openSync(fifo, 'r+');
381 const state = () => ({
382 screen: tm('capture-pane', '-p').replace(/\n+$/, '').split('\n'),
383 history: tm('capture-pane', '-p', '-S', '-', '-E', '-1').replace(/\n+$/, ''),
384 cursorY: Number(tm('display-message', '-p', '#{cursor_y}').trim()),
385 hist: Number(tm('display-message', '-p', '#{history_size}').trim()),
386 height: Number(tm('display-message', '-p', '#{pane_height}').trim()),
387 });
388 try {
389 tm('new-session', '-d', '-x', '40', '-y', String(rows), `cat ${fifo}`);
390 spawnSync('sleep', ['0.4']);
391 for (const l of lines) {
392 fs.writeSync(fd, `${l}\n`);
393 spawnSync('sleep', ['0.08']);
394 }
395 spawnSync('sleep', ['0.3']);
396 const before = state();
397 tm('resize-window', '-y', String(nr));
398 spawnSync('sleep', ['0.4']);
399 const after = state();
400 let grown = null;
401 if (back) {
402 tm('resize-window', '-y', String(back));
403 spawnSync('sleep', ['0.4']);
404 grown = state();
405 }
406 return { before, after, grown };
407 } finally {
408 try { fs.closeSync(fd); } catch (e) { /* already shut */ }
409 try { tm('kill-server'); } catch (e) { /* already gone */ }
410 try { fs.rmSync(fifo); } catch (e) { /* already gone */ }
411 }
412}
413
414// ┌───────────────────────────────────────────────────────────────┐
415// │ Breaking the code under test │
416// └───────────────────────────────────────────────────────────────┘
417
418/// What each named break does to the file it is a break of.
419///
420/// Each is a plausible bug rather than a nonsense edit: the substitution is
421/// what the code looked like before the property was there, or what a
422/// reasonable person would write if they had not thought about it.
423const PATCHES = {
424 // hand.js
425 'send-missing': ['js/hand.js', 'send: send,', ''],
426 'no-dispatch': ['js/hand.js', 'if (msg.id) toSubs(msg.id, msg);', ''],
427 'no-gone': ['js/hand.js', '\t\tsayGone(why);', ''],
428 'second-port': ['js/hand.js',
429 'if (link && link.greeted) return Promise.resolve(link);', 'link = null;'],
430 // What a person writes who has not met a port that dies between the
431 // handshake and the post: no guard, and a throw swallowed. The whole block
432 // goes, because the guard alone would still be covered by the catch.
433 'swallow-dead': ['js/hand.js',
434 '\t\t\tif (rec.dead || link !== rec) {',
435 '\t\t\ttry { rec.port.postMessage(msg); } catch (e) { /* gone */ }\n'
436 + '\t\t\treturn undefined;\n'
437 + '\t\t\t// eslint-disable-next-line no-unreachable\n'
438 + '\t\t\tif (rec.dead || link !== rec) {'],
439 // terminal.js — the shape the resize had before tmux was asked.
440 'resize-bottom': ['js/terminal.js', 'fitHeight(prim, pcur, nr);',
441 'var keep = prim.slice(Math.max(0, prim.length - nr));'
442 + ' var lost = prim.length - keep.length;'
443 + ' while (keep.length < nr) { var pl = newLine(cols); blankLine(pl, 0, cols, 0, 0, 0); keep.push(pl); }'
444 + ' prim.length = 0; for (var pi = 0; pi < keep.length; pi++) prim.push(keep[pi]);'
445 + ' pcur.y = Math.max(0, Math.min(nr - 1, pcur.y - lost));'],
446 'resize-nogrow': ['js/terminal.js',
447 '\t\t\t\tif (sb.length) {\n\t\t\t\t\tls.unshift(sb.pop());',
448 '\t\t\t\tif (false) {\n\t\t\t\t\tls.unshift(sb.pop());'],
449 'resize-noalt': ['js/terminal.js',
450 'var prim = S.modes.alt ? alt : lines;', 'var prim = lines;'],
451};
452
453/// The break currently in force, or ''. Read by the stub server.
454let breaking = '';
455
456/// The source of one served file, patched if a break names it.
457function served(rel) {
458 let src = fs.readFileSync(path.join(WWW, rel), 'utf8');
459 const p = PATCHES[breaking];
460 if (p && p[0] === rel) {
461 if (src.indexOf(p[1]) < 0) {
462 console.error(` !! the break "${breaking}" no longer matches ${rel}; `
463 + 'the patch is stale and would prove nothing');
464 process.exitCode = 3;
465 }
466 src = src.replace(p[1], p[2]);
467 }
468 return src;
469}
470
471// ── The wasm breaks ─────────────────────────────────────────────
472//
473// `pty_request` lives in the .wasm, so there is nothing to substitute at serve
474// time. Each break is therefore a whole second package, built from a patched
475// `src/wasm/pty.rs` with `--dev` (which skips wasm-opt and is four times
476// quicker). The pristine package is built the SAME way, so a broken run and a
477// whole run differ only in the patch.
478
479const PTY_RS = path.join(ROOT, 'src/wasm/pty.rs');
480const TOOLS_RS = path.join(ROOT, 'src/tools.rs');
481
482/// The wasm breaks, each one line of the engine turned into a plausible mistake.
483///
484/// `[file, from, to]`. Two files now: the composition lives in `src/wasm/pty.rs`
485/// and the fence it composes lives in `src/tools.rs`, and the defect this file
486/// missed for a fortnight was in the second one. A table that could only reach
487/// the first could not have proved it.
488const WASM_PATCHES = {
489 'w-term': [PTY_RS, 'None => fmt!("[]"),', 'None => fmt!(r#"[["TERM","xterm-256color"]]"#),'],
490 'w-nofence': [PTY_RS, 'if !fence_enforced(&machine.caps) {', 'if false {'],
491 'w-noroot': [PTY_RS, 'if !machine.rooted() {', 'if false {'],
492 'w-nopair': [PTY_RS, 'if extract_json_bool(&st, "paired") != Some(true) {', 'if false {'],
493 'w-nocwd': [PTY_RS, 'if !inside(&cwd, &fence.rw) && !inside(&cwd, &fence.ro) {', 'if false {'],
494 // THE DEFECT ITSELF, put back. Without the filter, `abs()` maps
495 // `diamonds/d1` under the granted root and the fence names a directory
496 // nothing creates — which is what shipped, and what the hand refused.
497 'w-storefence': [TOOLS_RS, ' .filter(|p| !is_store_path(p))\n', ''],
498 // And the other half: with the store `cwd` taken literally, a terminal is
499 // asked for in the Diamond's own directory and refused as outside its own
500 // fence, however many folders are attached to it.
501 'w-storecwd': [PTY_RS, 'if asked.is_empty() || crate::tools::is_store_path(&asked) {',
502 'if asked.is_empty() {'],
503 // The anchor carried `));` because the test used to sit inline inside
504 // `withholds_net(…)`. It was lifted into a binding on 2026-08-02 (05e3748) and
505 // this break has not matched since — so the property it exists to prove has
506 // not been proved for five days, and neither has `w-argvkit`, which is built
507 // after it. The build threw, the cache kept whatever had been made, and the
508 // guard above then skipped the rebuild for ever. Same intent, current shape.
509 'w-tainted': [PTY_RS, 'let tainted = extract_json_bool(ask, "tainted") == Some(true);',
510 'let tainted = false;'],
511 'w-argvkit': [PTY_RS, 'bounds.extend(toolkit_bounds(&extract_json_string_array(ask, "toolkits").unwrap_or_default()));',
512 'bounds.extend(toolkit_bounds(&extract_json_string_array(ask, "argv").unwrap_or_default()));'],
513};
514
515/// Builds one wasm package, patching the file the break names first where `name`
516/// is a break rather than the pristine build.
517///
518/// The file is restored whatever happens: a verifier that left a deliberate
519/// bug in the tree would be worse than no verifier.
520function buildWasm(name) {
521 const out = path.join(BROKEN, name, 'pkg');
522 const file = name === 'whole' ? PTY_RS : WASM_PATCHES[name][0];
523 const orig = fs.readFileSync(file, 'utf8');
524 // The file's own timestamps, kept so the restore below can put them back.
525 //
526 // The staleguard that protects this verifier compares MTIMES, not content, so
527 // restoring the bytes is not enough: writing `orig` back stamps the file
528 // `now`, which is necessarily later than the app's wasm was built, and the
529 // NEXT run of this file -- or of verify_scope or verify_wsident -- refuses
530 // with "that bundle is not this source". The verifier poisoned its own
531 // precondition, and three verifiers then read as red for a day with nothing
532 // wrong in the tree: `git diff` on pty.rs was empty every time anyone looked.
533 const when = fs.statSync(file);
534 try {
535 if (name !== 'whole') {
536 const [, from, to] = WASM_PATCHES[name];
537 if (orig.indexOf(from) < 0) {
538 throw new Error(`the wasm break "${name}" no longer matches `
539 + path.relative(ROOT, file));
540 }
541 fs.writeFileSync(file, orig.replace(from, to));
542 } else {
543 // The same bytes, written again on purpose. See the stamp below: the
544 // pristine build is the one that has nothing to change, and therefore
545 // the one cargo is most willing to skip.
546 fs.writeFileSync(file, orig);
547 }
548 // AND MAKE CARGO LOOK AT IT. The restore below puts the ORIGINAL mtime
549 // back, which the staleguard needs -- and which leaves the tree looking
550 // untouched to cargo, whose fingerprint is an mtime comparison. So the
551 // next build into this warm target directory finds a source no newer
552 // than the artefact already there and reuses it.
553 //
554 // That is not a slow build, it is a WRONG one. `whole` is built first and
555 // has no patch of its own, so what it reused was whatever was compiled
556 // last -- the final break of the previous run. Measured on 2026-08-24:
557 // `whole` and `w-argvkit` byte-identical at 18,867,059 bytes, and section
558 // 4 reporting `PROVED a toolkit is never inferred from argv (wasm) —
559 // broken=failed, correctly, whole=FAILED` on a tree with nothing whatever
560 // wrong with it. It bit only after `~/.cache/daimond/*/ptyedge-broken` was
561 // cleared while the cargo target directory beside it was left warm, which
562 // is why it survived every run before that one.
563 //
564 // The failure it produced is loud. The one it could produce is not: a
565 // break that reused the pristine artefact would go GREEN where it must go
566 // red, and `proved()` would report the check as blind. So the stamp is
567 // here rather than a note about clearing caches.
568 const now = new Date();
569 fs.utimesSync(file, now, now);
570 const env = Object.assign({}, process.env, {
571 CARGO_TARGET_DIR: TARGET_DIR,
572 RUSTFLAGS: `--remap-path-prefix=${os.homedir()}/.cargo=/cargo `
573 + `--remap-path-prefix=${ROOT}=/build`,
574 });
575 execFileSync('wasm-pack',
576 ['build', '--dev', '--target', 'web', '--out-dir', out],
577 { cwd: ROOT, env, stdio: 'pipe' });
578 } finally {
579 fs.writeFileSync(file, orig);
580 // Byte-identical content, so the original mtime is the truthful one.
581 try { fs.utimesSync(file, when.atime, when.mtime); } catch (e) { /* best effort */ }
582 }
583 return out;
584}
585
586// Build every package that is not already there — not "build them all if `whole`
587// is missing".
588//
589// The old guard asked only whether `whole` existed, and the packages are built in
590// a loop that takes minutes. Interrupt it once -- a timeout, a missing display,
591// Ctrl-C -- and the tree keeps `whole` plus however many breaks got made. Every
592// later run then sees `whole` and skips the loop entirely, so the first absent
593// package is loaded from a directory that does not exist, the page never reaches
594// `__ready`, and the run dies on a bare 30-second `waitForFunction` timeout that
595// names nothing. That is exactly how this file failed: six of eight packages on
596// disk, `w-tainted` and `w-argvkit` never built, and nothing in the output said
597// so. Checking each one costs a `statSync` and makes a partial build
598// self-repairing.
599const PKGS = ['whole', ...Object.keys(WASM_PATCHES)];
600
601/// The engine inside the package built for the break `name`, or 0 where there
602/// is no such package.
603const pkgBuilt = (name) => {
604 const f = path.join(BROKEN, name, 'pkg/oxedyne_daimond.js');
605 try { return fs.statSync(f).mtimeMs; } catch (e) { return 0; }
606};
607
608// ── And rebuild the ones that are there but are not this tree's ─────
609//
610// Absence was the only thing asked about, and absence is the easy half. A
611// package that IS there is loaded and believed however old it is, and the
612// packages live in `~/.cache/daimond`, which nothing ever clears. On 2026-08-24
613// six of the ten on this machine dated from 2 August: for twenty-two days every
614// `PROVED` pair in section 4 was a three-week-old engine going red against a
615// today engine going green, and the pair was reported as a proof. That is the
616// same fail-open the guards at the top of this file exist to close, one
617// directory further down — and worse, because those guards refuse while this
618// one certified.
619//
620// `newestSrc` is every `.rs` under `src/`, so a change anywhere below the pty
621// edge counts. It is NOT known to be older than `www/pkg`: the guard at the top
622// of this file judges the app's bundle on content now, and a bundle built in
623// another tree is legitimately older by the clock than the source it was built
624// from. So a package older than `newestSrc` is rebuilt rather than believed,
625// which in a frozen worktree means rebuilding all of them -- the safe direction,
626// and the one the 2,400-second budget in `slow_for` is sized for.
627// `buildWasm` restores the mtime of every file it patches, so the comparison
628// does not drift by a build.
629const staleFrom = newestSrc ? newestSrc.t : 0;
630const missing = PKGS.filter((n) => !pkgBuilt(n));
631const rotted = PKGS.filter((n) => pkgBuilt(n) && pkgBuilt(n) < staleFrom);
632if (PROVE || missing.length || rotted.length) {
633 const todo = PROVE ? PKGS : [...missing, ...rotted];
634 if (rotted.length && !PROVE) {
635 console.log(`\n${rotted.length} wasm package(s) are older than ${newestSrc.f} and are`
636 + ` being rebuilt: ${rotted.join(', ')}.`);
637 }
638 // ── Nothing is patched while somebody is editing it ─────────
639 //
640 // `buildWasm` writes a deliberate bug into `src/wasm/pty.rs` or
641 // `src/tools.rs` and puts the file back afterwards from the bytes it read
642 // at the start. This is a SHARED TREE with other agents working in it, so
643 // that restore is a `git checkout --` in slow motion: an edit made between
644 // the read and the write is gone, and gone silently, since the file it is
645 // restored to is a file that compiled. Refusing while the file is dirty is
646 // the whole fix — an uncommitted change is exactly the state in which
647 // somebody's work is at risk, and a clean file cannot lose anything the
648 // restore does not put back.
649 const dirty = [];
650 for (const f of [PTY_RS, TOOLS_RS]) {
651 const st = spawnSync('git', ['status', '--porcelain', '--', f],
652 { cwd: ROOT, encoding: 'utf8' });
653 // Not a git tree (the mirror is not always one), so there is nothing to
654 // compare against and nothing that can be said about it.
655 if (st.status !== 0) continue;
656 if ((st.stdout || '').trim()) dirty.push(path.relative(ROOT, f));
657 }
658 if (dirty.length) {
659 console.error(`\nThis run would have patched and restored ${dirty.join(' and ')}, which `
660 + `${dirty.length > 1 ? 'have' : 'has'} uncommitted changes. The restore writes back the `
661 + `bytes read before the build, so it would silently discard that work — and this tree is `
662 + `shared. Commit or set the change aside and run this again.`);
663 process.exit(2);
664 }
665 console.log(`\nBuilding ${todo.length} wasm package(s) the pty_request checks are proved`
666 + ` against${PROVE ? '' : ` (${PKGS.length - todo.length} already built)`}.`);
667 for (const name of todo) {
668 process.stdout.write(` building ${name} … `);
669 const t0 = Date.now();
670 buildWasm(name);
671 console.log(`${((Date.now() - t0) / 1000).toFixed(0)}s`);
672 }
673}
674
675/// Which wasm package the page is loading: '' for the one in www/pkg, or a name
676/// under the broken tree.
677let usingPkg = '';
678
679// ┌───────────────────────────────────────────────────────────────┐
680// │ The page │
681// └───────────────────────────────────────────────────────────────┘
682//
683// The relay, the terminal renderer and the wasm module, and nothing else: this
684// is what the app has under its Terminal panel, without the app. The panel
685// itself is verified by dev/verify_termpanel.mjs against the same surface.
686
687const PAGE = `<!doctype html><meta charset="utf-8"><title>ptyedge</title>
688<body>
689<div id="host" style="width:720px;height:360px"></div>
690<script src="/js/terminal.js"><\/script>
691<script src="/js/hand.js"><\/script>
692<script src="/js/handpty.js"><\/script>
693<script type="module">
694import init, * as W from '/pkg/oxedyne_daimond.js';
695await init();
696window.Wasm = W;
697window.__ready = true;
698<\/script>
699<script>
700/// Everything one session has said, for the far end to read back.
701window.__watch = function () {
702 window.__seen = { bytes: [], gaps: [], errs: [], closed: null, order: [] };
703 return {
704 onOutput: function (b) {
705 window.__seen.order.push('out');
706 window.__seen.bytes.push(Array.prototype.slice.call(b));
707 },
708 onGap: function (g) { window.__seen.order.push('gap'); window.__seen.gaps.push(g); },
709 onError: function (e) { window.__seen.order.push('err'); window.__seen.errs.push(e); },
710 onClosed: function (c) { window.__seen.order.push('closed'); window.__seen.closed = c; },
711 };
712};
713/// Everything the relay carried for one id, raw, so the ORDER can be asserted.
714window.__tap = function (id) {
715 window.__raw = [];
716 return DaimondHand.subscribe(id, function (m) { window.__raw.push(m); });
717};
718<\/script>
719</body>`;
720
721const server = http.createServer((req, res) => {
722 const url = (req.url || '').split('?')[0];
723 const send = (type, body) => { res.writeHead(200, { 'content-type': type }); res.end(body); };
724 if (url === '/favicon.ico') { res.writeHead(404); return res.end('no'); }
725 if (url.startsWith('/js/')) {
726 return send('text/javascript; charset=utf-8', served(url.slice(1)));
727 }
728 if (url.startsWith('/pkg/')) {
729 const base = usingPkg ? path.join(BROKEN, usingPkg, 'pkg') : path.join(WWW, 'pkg');
730 const f = path.join(base, url.slice('/pkg/'.length));
731 if (!fs.existsSync(f)) { res.writeHead(404); return res.end('no'); }
732 return send(f.endsWith('.wasm') ? 'application/wasm' : 'text/javascript; charset=utf-8',
733 fs.readFileSync(f));
734 }
735 return send('text/html; charset=utf-8', PAGE);
736});
737
738/// The first free port from `from`, so a dev server already holding one is left
739/// alone rather than fought over.
740async function listen(from) {
741 for (let port = from; port < from + 40; port++) {
742 try {
743 await new Promise((resolve, reject) => {
744 server.once('error', reject);
745 server.listen(port, '127.0.0.1', () => { server.removeListener('error', reject); resolve(); });
746 });
747 return port;
748 } catch (e) { if (e.code !== 'EADDRINUSE') throw e; }
749 }
750 throw new Error(`No free port from ${from}.`);
751}
752const PORT = await listen(Number(process.env.PTYEDGE_PORT || 8797));
753const APP = `http://127.0.0.1:${PORT}`;
754
755// ┌───────────────────────────────────────────────────────────────┐
756// │ The browser, the extension and the real hand │
757// └───────────────────────────────────────────────────────────────┘
758
759const { extDev } = await import(pathToFileURL(path.join(HERE, 'extdev.mjs')).href);
760const EXT_DEV = await extDev(PORT);
761
762fs.mkdirSync(path.join(PROFILE, 'NativeMessagingHosts'), { recursive: true });
763fs.writeFileSync(path.join(PROFILE, 'NativeMessagingHosts/com.oxedyne.daimond.hand.json'),
764 JSON.stringify({
765 name: 'com.oxedyne.daimond.hand',
766 description: 'The real hand, for verify_ptyedge.mjs.',
767 path: HAND,
768 type: 'stdio',
769 allowed_origins: ['chrome-extension://mpliijponglmmffjnonahhignkpkhmij/'],
770 }, null, '\t') + '\n');
771
772const b = await chromium.launchPersistentContext(PROFILE, {
773 executablePath: CHROME,
774 headless: false,
775 args: ['--no-sandbox', '--disable-dev-shm-usage',
776 `--disable-extensions-except=${EXT_DEV}`, `--load-extension=${EXT_DEV}`],
777 viewport: { width: 1200, height: 800 },
778 // The hand is told which folder it was granted through its environment,
779 // which it inherits from the browser that launched it. There is no third
780 // answer by design: a hand that guessed a root would be guessing what a
781 // command may touch.
782 env: Object.assign({}, process.env, {
783 DAIMOND_HAND_ROOT: GRANT,
784 DAIMOND_HAND_JOURNAL_DIR: path.join(WORK, 'journal'),
785 }),
786});
787
788const page = b.pages()[0] || await b.newPage();
789const errs = [];
790page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()); });
791page.on('pageerror', (e) => errs.push(`pageerror: ${e.message}`));
792
793/// Loads the page again, with whatever break and whichever wasm package are in
794/// force. The reload is what puts a patched file into the running page.
795async function reload() {
796 await page.goto(APP, { waitUntil: 'domcontentloaded' });
797 await page.waitForFunction(() => window.__ready === true, null, { timeout: 30000 });
798 await page.evaluate(() => {
799 // Tens of seconds is right for a person answering an approval window
800 // and wrong for a test that has already answered it.
801 if (window.DaimondPty && DaimondPty._setWaitsForTest) DaimondPty._setWaitsForTest({ open: 20000 });
802 if (window.DaimondHand && DaimondHand._setWaitsForTest) DaimondHand._setWaitsForTest({ hello: 20000, grace: 20000 });
803 // ── The folder verdict, stood in for ────────────────────────
804 //
805 // `hand/REVIEW.md` §1.14 is armed: the relay refuses to pair where the
806 // folder the page has open cannot be shown to be the folder the hand was
807 // granted. This page has no folder at all — it is three script tags, and
808 // even the app cannot get one without `showDirectoryPicker()`, a native
809 // dialog no harness can answer — so the verdict here is always a refusal
810 // and there is no arrangement of this file in which it is not. What is
811 // under test below is the COMPOSITION of a terminal request and a real pty
812 // on this machine, so the verdict is stood in for, as `dev/verify_scope.mjs`
813 // does. `dev/verify_wsident.mjs` tests the refusal itself, and section 3
814 // asserts it here once against the real hand before this takes effect.
815 //
816 // Only the verdict. The hand's own account of itself — its root, its caps,
817 // its os — passes through untouched, and the fence every request composes
818 // is built from it.
819 var real = window.DaimondHand.status;
820 window.__realStatus = function () { return real.call(window.DaimondHand); };
821 window.DaimondHand.status = function () {
822 return real.call(window.DaimondHand).then(function (raw) {
823 var st = JSON.parse(raw);
824 if (st.workspace && st.workspace !== 'ok') {
825 st.paired = true;
826 delete st.reason;
827 st.workspace = 'stood in for by verify_ptyedge.mjs';
828 }
829 return JSON.stringify(st);
830 });
831 };
832 });
833}
834
835/// Finds the extension's grant window and answers it. It is the extension's own
836/// page, so the click is a real click and the answer is the real answer.
837async function grant(answer = 'allow', ms = 15000) {
838 const until = Date.now() + ms;
839 while (Date.now() < until) {
840 for (const p of b.pages()) {
841 if (/grant\.html/.test(p.url())) {
842 await p.waitForLoadState('domcontentloaded');
843 await sleep(250);
844 await p.click(answer === 'allow' ? '#allow' : '#deny');
845 return true;
846 }
847 }
848 await sleep(150);
849 }
850 return false;
851}
852
853/// Every hand pid on the machine right now, launcher arms excluded.
854function handPids() {
855 const r = spawnSync('pgrep', ['-fa', HAND], { encoding: 'utf8' });
856 return (r.stdout || '').split('\n')
857 // The launcher arm is the same binary re-executed to apply a fence and
858 // then become the command, so it is not a second host.
859 .filter((l) => l.trim() && l.indexOf('--daimond-hand-launch') < 0)
860 .map((l) => l.trim().split(/\s+/)[0]);
861}
862
863/// The hands that were ALREADY running before this verifier started, which are
864/// none of its business.
865///
866/// `hands()` used to count every `daimond-hand` on the machine. On this developer's
867/// own workstation that includes the one serving their REAL browser -- a hand
868/// parented to /opt/google/chrome/chrome, paired to their live Daimond session and
869/// running for days. So the one-link check read 2 and failed, on a machine where
870/// nothing was wrong, purely because the person running the suite also uses the
871/// product. Killing it is not an option: it is a live session, not a leftover.
872const HANDS_BEFORE = new Set(handPids());
873
874/// How many hand processes THIS RUN has started. The one-link property is what
875/// keeps it at one: Chrome starts a fresh host per connection, so a second port
876/// is a second process and a second approval question.
877function hands() {
878 return handPids().filter((pid) => !HANDS_BEFORE.has(pid)).length;
879}
880
881// ┌───────────────────────────────────────────────────────────────┐
882// │ 1. The screen model, against tmux │
883// └───────────────────────────────────────────────────────────────┘
884
885await reload();
886
887/// The same case, run through the screen model in the page.
888///
889/// # Arguments
890/// * `rows` - The height the screen opens at.
891/// * `lines` - The lines printed before the resize.
892/// * `nr` - The height it is resized to.
893/// * `back` - An optional second height, to grow back to.
894async function modelResize(rows, lines, nr, back) {
895 return await page.evaluate(([rows, lines, nr, back]) => {
896 const S = DaimondTerminal.screen(40, rows, { scrollback: 5000 });
897 // A pty translates a program's \n into \r\n on the way out, so that is
898 // what a terminal is actually fed.
899 for (const l of lines) S.write(l + '\r\n');
900 const read = () => {
901 S.compose(true);
902 const screen = [];
903 for (let y = 0; y < S.rows; y++) screen.push(S.lineText(S.absOfRow(y)));
904 const history = [];
905 for (let a = S.absTop(); a < S.absTop() + S.scrollback(); a++) history.push(S.lineText(a));
906 return { screen, history: history.join('\n'), cursorY: S.cursor.y,
907 hist: S.scrollback(), height: S.rows };
908 };
909 const before = read();
910 S.resize(40, nr);
911 const after = read();
912 let grown = null;
913 if (back) { S.resize(40, back); grown = read(); }
914 return { before, after, grown };
915 }, [rows, lines, nr, back]);
916}
917
918/// tmux pads a captured pane's short lines with nothing and the model returns
919/// the same, but a trailing run of empty rows is written differently by the two
920/// (tmux drops them from `capture-pane`). Compared on the rows that have text.
921const textOf = (rows) => rows.map((s) => s.replace(/\s+$/, '')).filter((s) => s !== '');
922
923console.log('\n── 1. The screen model, against tmux ─────────────────');
924
925const T1 = tmuxResize(20, ['one', 'two'], 10);
926const M1 = await modelResize(20, ['one', 'two'], 10);
927check('tmux: shrinking 20 rows to 10 with the cursor at row 2 keeps both lines',
928 textOf(T1.after.screen).join('|') === 'one|two' && T1.after.cursorY === 2 && T1.after.hist === 0,
929 `tmux says ${JSON.stringify(textOf(T1.after.screen))} cursor=${T1.after.cursorY} hist=${T1.after.hist}`);
930check('the model does what tmux does on that shrink',
931 textOf(M1.after.screen).join('|') === textOf(T1.after.screen).join('|')
932 && M1.after.cursorY === T1.after.cursorY && M1.after.hist === T1.after.hist,
933 `model ${JSON.stringify(textOf(M1.after.screen))} cursor=${M1.after.cursorY} hist=${M1.after.hist}`);
934
935const L15 = Array.from({ length: 15 }, (_, i) => `L${i + 1}`);
936const T2 = tmuxResize(20, L15, 10, 20);
937const M2 = await modelResize(20, L15, 10, 20);
938check('tmux: with the cursor below the new height the top goes into the history',
939 textOf(T2.after.screen).join('|') === 'L7|L8|L9|L10|L11|L12|L13|L14|L15'
940 && T2.after.cursorY === 9 && T2.after.hist === 6,
941 `tmux says ${JSON.stringify(textOf(T2.after.screen))} cursor=${T2.after.cursorY} hist=${T2.after.hist}`);
942check('the model does what tmux does on that shrink',
943 textOf(M2.after.screen).join('|') === textOf(T2.after.screen).join('|')
944 && M2.after.cursorY === T2.after.cursorY && M2.after.hist === T2.after.hist,
945 `model ${JSON.stringify(textOf(M2.after.screen))} cursor=${M2.after.cursorY} hist=${M2.after.hist}`);
946check('tmux: growing back takes those lines out of the history again',
947 textOf(T2.grown.screen).join('|') === L15.join('|') && T2.grown.cursorY === 15 && T2.grown.hist === 0,
948 `tmux says cursor=${T2.grown.cursorY} hist=${T2.grown.hist}`);
949check('the model does what tmux does on the way back',
950 textOf(M2.grown.screen).join('|') === textOf(T2.grown.screen).join('|')
951 && M2.grown.cursorY === T2.grown.cursorY && M2.grown.hist === T2.grown.hist,
952 `model cursor=${M2.grown.cursorY} hist=${M2.grown.hist}`);
953
954// The panel's own case, which is what a user meets: the shrink happens, and
955// then the program prints again.
956const M3 = await page.evaluate(() => {
957 const S = DaimondTerminal.screen(40, 20, { scrollback: 5000 });
958 S.write('one\r\ntwo\r\n');
959 S.resize(40, 10);
960 S.write('three\r\n');
961 const out = [];
962 for (let a = S.absTop(); a < S.absTop() + S.scrollback() + S.rows; a++) out.push(S.lineText(a));
963 while (out.length && out[out.length - 1] === '') out.pop();
964 return out;
965});
966check('a panel resized mid-session keeps the screen the reader was reading',
967 M3.join('|') === 'one|two|three', `the model holds ${JSON.stringify(M3)}`);
968
969const M4 = await page.evaluate(() => {
970 const S = DaimondTerminal.screen(40, 20, { scrollback: 5000 });
971 S.write('shell line\r\n');
972 S.write('\x1b[?1049h'); // into the alternate screen, cursor saved
973 S.write('ALT');
974 S.resize(30, 10);
975 S.write('\x1b[?1049l'); // and back out of it
976 S.compose(true);
977 const screen = [];
978 for (let y = 0; y < S.rows; y++) screen.push(S.lineText(S.absOfRow(y)));
979 return { cols: S.cols, rows: S.rows, width: S.cells.ch.length, screen };
980});
981check('the parked primary screen comes back at the new size, not the old one',
982 M4.width === M4.cols * M4.rows && M4.screen[0] === 'shell line',
983 `cols=${M4.cols} rows=${M4.rows} first row ${JSON.stringify(M4.screen[0])}`);
984
985// ┌───────────────────────────────────────────────────────────────┐
986// │ 2. pty_request composes the wire's own open │
987// └───────────────────────────────────────────────────────────────┘
988//
989// The hand's answer is what a machine says about itself, so it is the thing
990// stood in for here: the code under test is the composition, and a machine that
991// cannot fence, or that never said which folder it granted, is a machine and
992// not a stub of this file's making. Every one of these is proved against a wasm
993// built with the corresponding line removed.
994
995console.log('\n── 2. pty_request ────────────────────────────────────');
996
997// What the Terminal panel sends, spelled as the panel spells it — including the
998// `cwd`, which is the Diamond's OWN directory (`cwd: b.own_dir` where the panel
999// composes this in www/js/daimond.js). A question answered against a tidier
1000// request than the app makes is a question about a different app.
1001const ASK = {
1002 own_dir: OWN_DIR,
1003 attached: [WORK_DIR, 'shared'],
1004 read_only: ['shared'],
1005 cwd: OWN_DIR,
1006 cols: 100,
1007 rows: 30,
1008};
1009
1010/// Ask the wasm for a request, with the hand's answer stood in for.
1011///
1012/// # Arguments
1013/// * `ask` - What the panel would send, with anything overridden.
1014/// * `st` - What the relay's `status()` should answer, or null for the real one.
1015async function request(ask, st) {
1016 return await page.evaluate(async ([ask, st]) => {
1017 const real = window.DaimondHand.status;
1018 if (st) window.DaimondHand.status = () => Promise.resolve(JSON.stringify(st));
1019 try { return JSON.parse(await window.Wasm.pty_request(JSON.stringify(ask))); }
1020 finally { window.DaimondHand.status = real; }
1021 }, [ask, st || null]);
1022}
1023
1024const FENCED = {
1025 paired: true, transport: 'machine', machine: 'test', os: 'linux',
1026 root: GRANT, caps: ['fence:linux', `root:${GRANT}`, `home:${os.homedir()}`],
1027};
1028
1029/// Every pty_request property, as one function so the same code answers for the
1030/// whole wasm and for each broken one.
1031const REQ_CHECKS = {
1032 'composes the wire\'s own open, with the fence in it': async () => {
1033 const r = await request(ASK);
1034 return r.t === 'open' && !!r.fence
1035 && r.fence.rw.indexOf(`${GRANT}/${WORK_DIR}`) >= 0
1036 && r.fence.rw.indexOf(`${GRANT}/shared`) < 0
1037 && r.fence.ro.indexOf(`${GRANT}/shared`) >= 0
1038 && r.fence.deny.indexOf(`${GRANT}/.daimond`) >= 0
1039 && r.size.cols === 100 && r.size.rows === 30;
1040 },
1041 // The Diamond's own directory is in the browser's storage, so a fence naming
1042 // it names a path nothing on this machine ever makes — and the hand refuses
1043 // the WHOLE spec over one such path, the user's real folder along with it.
1044 // Asserted over the fence entire, because it was equally fatal in any list.
1045 'names no path inside Daimond\'s own storage, which is not on this disk': async () => {
1046 const r = await request(ASK);
1047 return JSON.stringify(r.fence).indexOf(`${GRANT}/diamonds`) < 0;
1048 },
1049 // And it still has to start somewhere. The panel asks for the Diamond, which
1050 // is nowhere; `tools::start_dir` answers with the first folder attached to
1051 // it, the same rule a command starts by.
1052 'starts the session in the attached folder, though the panel asked for the Diamond': async () => {
1053 const r = await request(ASK);
1054 return r.cwd === `${GRANT}/${WORK_DIR}`;
1055 },
1056 // A wall with a door in it. This refusal is shown to the USER verbatim, in
1057 // the Terminal panel, so it has to name the thing they can do about it.
1058 'refuses a Diamond with nothing attached, and says to attach a folder': async () => {
1059 const r = await request(Object.assign({}, ASK, { attached: [], read_only: [] }));
1060 return !!r.refused && !r.t && /attach/i.test(r.refused)
1061 && /Workspace panel/.test(r.refused);
1062 },
1063 'sends no TERM, which the hand sets and refuses a caller for naming': async () => {
1064 const r = await request(ASK);
1065 return Array.isArray(r.env) && !r.env.some((p) => p[0] === 'TERM');
1066 },
1067 'refuses a hand that cannot fence a program': async () => {
1068 const r = await request(ASK, Object.assign({}, FENCED, { caps: ['fence:none', `root:${GRANT}`] }));
1069 return !!r.refused && /cannot fence/.test(r.refused) && !r.t;
1070 },
1071 'refuses a hand that said nothing about fencing': async () => {
1072 const r = await request(ASK, Object.assign({}, FENCED, { caps: [`root:${GRANT}`] }));
1073 return !!r.refused && /did not say it can fence/.test(r.refused) && !r.t;
1074 },
1075 'refuses a hand that did not say which folder it was granted': async () => {
1076 const r = await request(ASK, Object.assign({}, FENCED, { root: '' }));
1077 return !!r.refused && /which folder it was granted/.test(r.refused) && !r.t;
1078 },
1079 'refuses when no hand is paired, in the relay\'s own words': async () => {
1080 const r = await request(ASK, { paired: false, caps: [], reason: 'THE RELAY SAID THIS.' });
1081 return !!r.refused && r.refused.indexOf('THE RELAY SAID THIS.') >= 0 && !r.t;
1082 },
1083 'refuses a working directory outside what the Diamond may touch': async () => {
1084 const r = await request(Object.assign({}, ASK, { cwd: '.daimond' }));
1085 return !!r.refused && /outside what this Diamond may touch/.test(r.refused) && !r.t;
1086 },
1087 'a tainted session loses the network': async () => {
1088 const clean = await request(ASK);
1089 const dirty = await request(Object.assign({}, ASK, { tainted: true }));
1090 return clean.fence.net === true && dirty.fence.net === false;
1091 },
1092 'a toolkit comes from what the user granted, never from argv': async () => {
1093 const asked = await request(Object.assign({}, ASK, { argv: ['cargo', 'test'] }));
1094 const granted = await request(Object.assign({}, ASK, { toolkits: ['rust'] }));
1095 const kit = `${os.homedir()}/.cargo/bin`;
1096 return asked.fence.ro.indexOf(kit) < 0
1097 && !asked.env.some((p) => p[0] === 'CARGO_HOME')
1098 && granted.fence.ro.indexOf(kit) >= 0
1099 && granted.env.some((p) => p[0] === 'CARGO_HOME');
1100 },
1101};
1102
1103/// Runs one named pty_request check.
1104async function reqCheck(name) {
1105 try { return await REQ_CHECKS[name](); } catch (e) { return false; }
1106}
1107
1108for (const name of Object.keys(REQ_CHECKS)) {
1109 check(`pty_request ${name}`, await reqCheck(name));
1110}
1111
1112// A few more that no broken build is built for, because they are not one line
1113// to remove: the defaults, and the ceilings.
1114{
1115 const bare = await request(
1116 { own_dir: OWN_DIR, attached: [WORK_DIR], read_only: [], cwd: OWN_DIR });
1117 check('pty_request opens a shell when nobody named a program',
1118 bare.argv.join(' ') === '/bin/sh', `argv is ${JSON.stringify(bare.argv)}`);
1119 check('pty_request assumes 80x24 when the page did not say',
1120 bare.size.cols === 80 && bare.size.rows === 24, JSON.stringify(bare.size));
1121 const named = await request(Object.assign({}, ASK, { argv: ['/bin/sh', '-c', 'true'] }));
1122 check('pty_request honours an argv it was given',
1123 named.argv.join(' ') === '/bin/sh -c true', JSON.stringify(named.argv));
1124 const huge = await request(Object.assign({}, ASK, { cols: 99999, rows: 0 }));
1125 check('pty_request holds the size to what the wire can carry',
1126 huge.size.cols === 2000 && huge.size.rows === 24, JSON.stringify(huge.size));
1127 const nowhere = await request({ own_dir: '', attached: [], read_only: [], cwd: '' });
1128 check('pty_request refuses a Diamond whose bounds name nowhere',
1129 !!nowhere.refused && !nowhere.t, JSON.stringify(nowhere).slice(0, 120));
1130 const win = await request(ASK, Object.assign({}, FENCED, { os: 'windows' }));
1131 check('pty_request refuses a machine with no pseudo-terminals',
1132 !!win.refused && /Windows/.test(win.refused), JSON.stringify(win).slice(0, 120));
1133 const mine = await request(ASK);
1134 check('pty_request mints no id, leaving that to the end that knows the page\'s sessions',
1135 mine.id === undefined, JSON.stringify(mine.id));
1136 // Printed rather than asserted: what a reader wants from this file is the
1137 // request itself, and a check that only says "true" hides it.
1138 console.log(` the request it composed: ${JSON.stringify(mine)}`);
1139}
1140
1141// ┌───────────────────────────────────────────────────────────────┐
1142// │ 3. End to end: a real terminal on this machine │
1143// └───────────────────────────────────────────────────────────────┘
1144
1145console.log('\n── 3. A real pty, through the real extension and hand ─');
1146
1147/// Opens a session for real and returns what happened.
1148async function openReal(ask) {
1149 return await page.evaluate(async (ask) => {
1150 const req = JSON.parse(await window.Wasm.pty_request(JSON.stringify(ask)));
1151 if (req.refused) return { refused: req.refused };
1152 window.__req = req;
1153 const subs = window.__watch();
1154 try {
1155 const live = await DaimondPty.open(req, subs);
1156 window.__sid = live.id;
1157 return { id: live.id, pid: live.pid };
1158 } catch (e) {
1159 return { refused: (e && e.message) || String(e) };
1160 }
1161 }, ask);
1162}
1163
1164// One counter behind every probe id in this file, so no two attempts can name
1165// the same terminal.
1166let probeSeq = 0;
1167
1168/// Opens a terminal by sending the wire message on the link directly, and
1169/// reports what the relay handed to a subscriber watching that id.
1170///
1171/// The low road, deliberately: `DaimondPty` is not in the way, so what is
1172/// proved is hand.js's own multiplexing rather than the relay above it.
1173///
1174/// A FRESH ID EVERY TIME, and the session ended before the id is let go. This
1175/// took a fixed `probe-1`, and §4 runs it TWICE -- once against broken code and
1176/// once against whole -- so the second attempt asked for a terminal whose id was
1177/// still held by the first. `hand/src/pty.rs:437` refuses a duplicate id, and
1178/// rightly; what came back was that refusal rather than an answer about
1179/// dispatch, and the proof read it as the whole code failing. Two halves of one
1180/// proof have to be two independent trials, which means neither may leave
1181/// anything behind for the other to trip on.
1182async function probeOpen(tag = 'probe', tries = 3) {
1183 for (let n = 0; n < tries; n++) {
1184 const said = await probeOnce(tag + '-' + (++probeSeq));
1185 // A link that would not open at all is not the property under test, and
1186 // it happens: the previous host is still exiting and the next one
1187 // cannot take the journal's lock yet. Retried, so that a red result
1188 // means the subscriber heard nothing rather than that nothing was sent.
1189 if (!/^send rejected|^evaluate threw/.test(said)) {
1190 if (!/opened/.test(said)) console.log(` (the probe was told: ${String(said).slice(0, 300)})`);
1191 return said;
1192 }
1193 await sleep(1500);
1194 await reload();
1195 }
1196 console.log(' (the link would not open at all, three times over)');
1197 return '';
1198}
1199
1200/// One attempt at that.
1201async function probeOnce(id) {
1202 const r = await Promise.all([
1203 page.evaluate(async ([root, id]) => {
1204 window.__heard = [];
1205 const off = DaimondHand.subscribe(id, (m) => window.__heard.push(
1206 m.t + (m.t === 'refused' || m.t === 'error' ? ':' + (m.reason || m.message || '') : '')));
1207 const sent = await DaimondHand.send({
1208 t: 'open', id: id, argv: ['/bin/sh'], cwd: root,
1209 env: [], size: { cols: 20, rows: 5 },
1210 fence: { rw: [root], ro: [], deny: [], net: false },
1211 }).then(() => '', (e) => 'send rejected: ' + ((e && e.message) || e));
1212 await new Promise((r2) => setTimeout(r2, 2500));
1213 // End the session before the id is let go. Best effort: a probe that
1214 // never opened one has nothing to end, and the answer below is the
1215 // same either way.
1216 await DaimondHand.send({ t: 'signal', id: id, sig: 'term' }).catch(() => {});
1217 await new Promise((r2) => setTimeout(r2, 300));
1218 off();
1219 return (sent ? sent + ' | ' : '') + window.__heard.join(',');
1220 }, [GRANT, id]).catch((e) => 'evaluate threw: ' + e.message),
1221 allow(),
1222 ]);
1223 return r[0];
1224}
1225
1226/// Types at a session and waits for the program to answer.
1227async function typed(text, ms = 2500) {
1228 await page.evaluate((t) => DaimondPty.input(window.__sid, t), text);
1229 await sleep(ms);
1230 return await page.evaluate(() => {
1231 const all = [].concat.apply([], window.__seen.bytes);
1232 return new TextDecoder().decode(new Uint8Array(all));
1233 });
1234}
1235
1236const opened = await Promise.all([openReal(ASK), grant('allow')]).then((r) => r[0]);
1237/// The origin is granted once and remembered, so every later open finds no
1238/// window at all; the short wait is what keeps this from costing a minute.
1239const allow = () => grant('allow', 4000);
1240check('a real terminal opens on this machine, through the extension and the hand',
1241 !!opened.id && opened.pid > 0, JSON.stringify(opened));
1242
1243// ── A granted toolchain, opened for real ────────────────
1244//
1245// The gap seq 150 shipped through, and the reason these are here rather than in
1246// section 2. `pty_request` composes a fence naming the toolchain the user
1247// granted -- `~/.gitconfig` for Git -- and that root is OUTSIDE the folder the
1248// hand was granted, so the extension lets it through only where the SAME request
1249// names the toolchain. Section 2 asks what was composed. Nothing, until now, ever
1250// put one of those through the extension's own vetting to a real hand, so the
1251// whole of what 7cfd538 restored -- that a Diamond with a toolchain ticked can
1252// open a terminal at all -- was measured by nothing, and a relay that dropped the
1253// field a second time would have gone green here exactly as it did the first.
1254//
1255// Two checks, because either alone proves nothing. The first says a granted
1256// toolchain opens; the second says the identical fence with the toolchain NOT
1257// named is refused, in the extension's own sentence -- so the first is not
1258// passing because nobody is looking.
1259const KIT_ASK = Object.assign({}, ASK, { toolkits: ['git'] });
1260const kitreq = await request(KIT_ASK);
1261const KIT_ROOT = `${os.homedir()}/.gitconfig`;
1262check('a granted toolchain travels in the fence the panel would send',
1263 Array.isArray(kitreq.toolkits) && kitreq.toolkits.indexOf('git') >= 0
1264 && Array.isArray(kitreq.fence && kitreq.fence.ro)
1265 && kitreq.fence.ro.indexOf(KIT_ROOT) >= 0,
1266 JSON.stringify({ toolkits: kitreq.toolkits, ro: kitreq.fence && kitreq.fence.ro }));
1267
1268// `openReal` puts its session in `window.__sid`, and everything below types at
1269// that. So the one this opens is closed again and the earlier session put back,
1270// or the rest of section 3 would be typing at a terminal this check had taken.
1271const PRIOR_SID = await page.evaluate(() => window.__sid);
1272const kitopen = await Promise.all([openReal(KIT_ASK), allow()]).then((r) => r[0]);
1273check('and a terminal in a Diamond granted that toolchain really opens',
1274 !!kitopen.id && kitopen.pid > 0, JSON.stringify(kitopen));
1275await page.evaluate(async (prior) => {
1276 if (window.__sid && window.__sid !== prior) {
1277 try { await DaimondPty.close(window.__sid, 'term'); } catch (e) { /* already gone */ }
1278 }
1279 window.__sid = prior;
1280}, PRIOR_SID);
1281await sleep(800);
1282
1283/// The same fence with the toolchain unnamed, sent down the link itself, so what
1284/// answers is the extension rather than anything this file believes about it.
1285const stripped = await Promise.all([page.evaluate(async (req) => {
1286 const bare = JSON.parse(JSON.stringify(req));
1287 delete bare.toolkits;
1288 bare.id = 'kit-stripped';
1289 const heard = [];
1290 const off = DaimondHand.subscribe(bare.id, (m) => heard.push(
1291 m.t + (m.t === 'refused' || m.t === 'error' ? ': ' + (m.reason || m.message || '') : '')));
1292 let sent = '';
1293 try { await DaimondHand.send(bare); }
1294 catch (e) { sent = 'send rejected: ' + ((e && e.message) || e); }
1295 await new Promise((r) => setTimeout(r, 2500));
1296 off();
1297 return sent || heard.join(' | ');
1298}, kitreq), allow()]).then((r) => r[0]);
1299check('and the same fence with no toolchain named is refused, in the extension\'s words',
1300 /granted no toolchain/.test(stripped), String(stripped).slice(0, 200));
1301
1302// ── §1.14, asked of the real hand, with nothing stood in for ────
1303//
1304// The relay's OWN answer, reached through `__realStatus`. The hand has a folder
1305// on this machine and this page has none, so the two cannot be shown to mean the
1306// same folder and the relay refuses the pairing outright — which is what a user
1307// with the wrong folder open meets, and the reason everything else in this file
1308// stands the verdict in. Asserted against the REAL hand, because a refusal that
1309// only ever fires against a stub is a refusal nobody has watched happen.
1310const owned = await page.evaluate(async (ask) => {
1311 const st = JSON.parse(await window.__realStatus());
1312 const stood = window.DaimondHand.status;
1313 window.DaimondHand.status = window.__realStatus;
1314 let req;
1315 try { req = JSON.parse(await window.Wasm.pty_request(JSON.stringify(ask))); }
1316 finally { window.DaimondHand.status = stood; }
1317 return { st, req };
1318}, ASK);
1319check('the relay refuses to pair a folder it cannot show is the hand\'s',
1320 owned.st.paired === false && owned.st.root === GRANT
1321 && /lives in the browser and not in a folder on this machine/.test(owned.st.reason || ''),
1322 JSON.stringify(owned.st).slice(0, 220));
1323check('and the engine opens no terminal on it, passing the sentence on whole',
1324 !!owned.req.refused && owned.req.t === undefined
1325 && /lives in the browser and not in a folder on this machine/.test(owned.req.refused),
1326 JSON.stringify(owned.req).slice(0, 220));
1327
1328let SAW = '';
1329if (opened.id) {
1330 // The marker is COMPOSED by the shell and never typed. A terminal echoes
1331 // what is typed at it, so a test looking for a word it had just sent finds
1332 // its own keystrokes and passes with the program removed entirely.
1333 SAW = await typed('t=IS; test -t 0 && echo "$t-A-TTY"\n');
1334 check('the program really has a terminal: test -t 0 says so',
1335 /IS-A-TTY/.test(SAW), JSON.stringify(SAW.slice(-160)));
1336
1337 SAW = await typed('stty size\n');
1338 check('the kernel reports the panel\'s own size to the program',
1339 new RegExp(`\\b${ASK.rows} ${ASK.cols}\\b`).test(SAW),
1340 `expected "${ASK.rows} ${ASK.cols}" in ${JSON.stringify(SAW.slice(-160))}`);
1341
1342 SAW = await typed('echo "sum-$((6*7))"\n');
1343 check('what is typed reaches the program, which answers it',
1344 /sum-42/.test(SAW), JSON.stringify(SAW.slice(-120)));
1345
1346 SAW = await typed('cat hello.txt\n');
1347 check('the session can read inside the fence',
1348 /inside the fence/.test(SAW), JSON.stringify(SAW.slice(-160)));
1349
1350 SAW = await typed('cat .daimond/secret.txt 2>&1 | tail -1\n');
1351 check('and cannot read the folder the fence denies',
1352 !/out of bounds/.test(SAW), JSON.stringify(SAW.slice(-200)));
1353
1354 // ── What a fenced terminal cannot reach, and what that costs ────
1355 //
1356 // `hand/src/pty.rs`'s own header says why this module exists: `sudo`, `ssh`,
1357 // `vim`, `git commit` and a REPL, none of which works down a pipe. `ssh` is
1358 // one of the five, and it is on the far side of the compartment -- and
1359 // NOTHING said so. "The terminal works" was true of the pty and false of the
1360 // terminal, and a request on 2026-08-24 for an ssh session that survives a
1361 // dropout was read as a question about persistence when the connection
1362 // cannot be made at all.
1363 //
1364 // Each is deliberate, and each is pinned HERE, in the sentence the kernel
1365 // actually produces, so that loosening one turns this red and is a decision
1366 // somebody takes rather than a side effect nobody notices:
1367 //
1368 // ~/.ssh denied by every fence -- `Toolkit::Git` in src/tools.rs
1369 // names it and says the private keys are what is being
1370 // kept from a command.
1371 // AF_UNIX refused unconditionally by `hand/src/seccomp.rs`, which
1372 // closes `REVIEW.md` §1.3 -- the session bus started a
1373 // process outside the fence. tmux and ssh-agent are both
1374 // on that socket, so both go with it.
1375 //
1376 // `screen` is left alone deliberately: it fails too, but by a different route
1377 // on a different machine -- `/run/screen` under one fence, a silent nothing
1378 // under this one -- and a check whose red is not the same red twice says less
1379 // than no check.
1380 //
1381 // Together they say the thing worth writing down. A session that survives a
1382 // dropout has to be held on the FAR machine -- `tmux` there is outside this
1383 // filter and would work -- but nothing here can reach that machine, and no
1384 // multiplexer can be started on this side either. A verifier that only ever
1385 // ran `echo` could not have told anyone that.
1386 SAW = await typed('ssh -o BatchMode=yes -o ConnectTimeout=3 127.0.0.1 true 2>&1 | tail -2\n', 6000);
1387 check('ssh cannot read the keys, so a terminal cannot reach another machine',
1388 /Host key verification failed|hostkeys_foreach failed/.test(SAW), JSON.stringify(SAW.slice(-200)));
1389
1390 SAW = await typed('tmux new-session -d -s edge 2>&1 | tail -1\n');
1391 check('and tmux cannot open its socket, so nothing here can be made to persist',
1392 /Operation not permitted/.test(SAW), JSON.stringify(SAW.slice(-200)));
1393
1394 // The renderer, fed the real bytes: this is the whole road, from a program
1395 // on the machine to the grid a person reads.
1396 const drawn = await page.evaluate(() => {
1397 const T = DaimondTerminal.create(document.getElementById('host'), { label: 'e2e' });
1398 for (const c of window.__seen.bytes) T.write(new Uint8Array(c));
1399 const out = T.screen.allText();
1400 T.destroy();
1401 return out;
1402 });
1403 check('the terminal renderer draws what the program wrote',
1404 /IS-A-TTY/.test(drawn) && /sum-42/.test(drawn), JSON.stringify(drawn.slice(-160)));
1405
1406 check('one hand process, however many conversations are on the link',
1407 hands() === 1, `${hands()} running`);
1408
1409 const heard = await probeOpen('probe-live');
1410 check('a wire message sent straight down the link is answered to its subscriber',
1411 /opened/.test(heard), heard.slice(0, 220));
1412
1413 const ord = await page.evaluate(() => window.__seen.order.join(','));
1414 check('the session reported no holes in its output', !/gap/.test(ord), ord.slice(0, 200));
1415}
1416
1417// ── The link, and what the relay does when it dies ──────────────
1418
1419const raw = await page.evaluate(() => {
1420 // A tap on the SAME id, watching the wire itself rather than the relay's
1421 // reading of it. Stopped again at once: a handler left attached would
1422 // double every message the next tap sees, which is a harness that breaks
1423 // its own check.
1424 const off = window.__tap(window.__sid);
1425 const isFn = typeof off === 'function';
1426 if (isFn) off();
1427 return isFn;
1428});
1429check('subscribe hands back the way to stop watching', raw === true);
1430
1431const seqOk = await page.evaluate(async () => {
1432 const off = window.__tap(window.__sid);
1433 await DaimondPty.input(window.__sid, 'echo seq-check\n');
1434 await new Promise((r) => setTimeout(r, 1200));
1435 off();
1436 const out = window.__raw.filter((m) => m.t === 'output');
1437 let rising = out.length > 0;
1438 for (let i = 1; i < out.length; i++) if (out[i].seq !== out[i - 1].seq + 1) rising = false;
1439 return { n: out.length, rising, types: window.__raw.map((m) => m.t).join(',') };
1440});
1441check('subscribe delivers every message for its id, in arrival order',
1442 seqOk.n > 0 && seqOk.rising, JSON.stringify(seqOk));
1443
1444// The link dies. Which sentence it produces is hand.js's decision and
1445// dev/verify_handrun.mjs proves that; what is proved here is that a subscriber
1446// is TOLD, with the sentence and with whether a hand was ever met.
1447const goneMsg = await page.evaluate(async () => {
1448 window.__gone = null;
1449 DaimondHand.subscribe(window.__sid, function (m) { if (m.t === '__gone') window.__gone = m; });
1450 const rejected = await DaimondHand.send({ t: 'input', id: window.__sid, data: 'AA==' })
1451 .then(() => '', (e) => (e && e.message) || String(e));
1452 // The link dies UNDER a send that is already on its way. `open` has already
1453 // resolved, so the post happens a microtask after the port went, which is
1454 // the shape of the "disconnected port object" failure: nothing has told the
1455 // relay yet, and it must not post into a corpse and call that a success.
1456 const inflight = DaimondHand.send({ t: 'input', id: window.__sid, data: 'AA==' })
1457 .then(() => '', (e) => (e && e.message) || String(e));
1458 DaimondHand.close();
1459 const caught = await inflight;
1460 await new Promise((r) => setTimeout(r, 300));
1461 return { gone: window.__gone, before: rejected, caught: caught };
1462});
1463check('a live link accepts a send without complaint', goneMsg.before === '', goneMsg.before);
1464check('a subscriber is told when the link dies, and whether a hand was ever met',
1465 !!goneMsg.gone && goneMsg.gone.met === true && typeof goneMsg.gone.message === 'string'
1466 && goneMsg.gone.message.length > 20, JSON.stringify(goneMsg.gone).slice(0, 160));
1467check('a send caught by the link dying under it rejects with a sentence',
1468 typeof goneMsg.caught === 'string' && goneMsg.caught.length > 20,
1469 JSON.stringify(goneMsg.caught).slice(0, 200));
1470const thrown = errs.filter((e) => e.indexOf('pageerror:') === 0);
1471check('nothing in the relay, the renderer or the wasm threw on the page',
1472 thrown.length === 0, thrown.slice(0, 3).join(' | '));
1473
1474// ┌───────────────────────────────────────────────────────────────┐
1475// │ 4. Every property, proved against the broken code │
1476// └───────────────────────────────────────────────────────────────┘
1477
1478console.log('\n── 4. Proved against broken code ─────────────────────');
1479
1480/// One source-level break: patch the file, reload, run, restore, reload, run.
1481async function provedSrc(name, brk, testIt) {
1482 await proved(name,
1483 async () => { breaking = brk; await reload(); },
1484 testIt,
1485 async () => { breaking = ''; await reload(); });
1486}
1487
1488await provedSrc('hand.js exports what a terminal needs', 'send-missing', async () => {
1489 const st = JSON.parse(await page.evaluate(() => DaimondPty.status()));
1490 return st.carries === true;
1491});
1492
1493await provedSrc('the model keeps the screen on a shrink', 'resize-bottom', async () => {
1494 const m = await modelResize(20, ['one', 'two'], 10);
1495 return textOf(m.after.screen).join('|') === 'one|two' && m.after.cursorY === 2;
1496});
1497
1498await provedSrc('growing takes the lines back out of the history', 'resize-nogrow', async () => {
1499 const m = await modelResize(20, L15, 10, 20);
1500 return textOf(m.grown.screen).join('|') === L15.join('|') && m.grown.cursorY === 15;
1501});
1502
1503await provedSrc('the parked screen is resized too', 'resize-noalt', async () => {
1504 const r = await page.evaluate(() => {
1505 const S = DaimondTerminal.screen(40, 20, { scrollback: 5000 });
1506 S.write('shell line\r\n');
1507 S.write('\x1b[?1049h');
1508 S.resize(30, 10);
1509 S.write('\x1b[?1049l');
1510 S.compose(true);
1511 return { first: S.lineText(S.absOfRow(0)), cols: S.cols, width: S.cells.ch.length, rows: S.rows };
1512 });
1513 return r.first === 'shell line' && r.width === r.cols * r.rows;
1514});
1515
1516// The three that need a live link are proved together, because each costs an
1517// approval window and a host process.
1518await provedSrc('a subscriber hears the wire', 'no-dispatch',
1519 async () => /opened/.test(await probeOpen()));
1520
1521await provedSrc('the link dies once, and everybody is told', 'no-gone', async () => {
1522 const r = await Promise.all([
1523 page.evaluate(async () => {
1524 window.__g = 0;
1525 DaimondHand.subscribe('probe-2', (m) => { if (m.t === '__gone') window.__g++; });
1526 await DaimondHand.send({ t: 'hello', proto: 1, client: 'probe' }).catch(() => {});
1527 DaimondHand.close();
1528 await new Promise((r2) => setTimeout(r2, 400));
1529 return window.__g;
1530 }).catch(() => 0),
1531 allow(),
1532 ]);
1533 return r[0] === 1;
1534});
1535
1536await provedSrc('a send caught by a dying link rejects rather than vanishing', 'swallow-dead',
1537 async () => {
1538 const r = await Promise.all([
1539 page.evaluate(async () => {
1540 await DaimondHand.send({ t: 'hello', proto: 1, client: 'probe' }).catch(() => {});
1541 // The port is torn down UNDER a send already on its way, which
1542 // is the shape of the intermittent failure this handles: the
1543 // post lands after the port went and before anything said so.
1544 const p = DaimondHand.send({ t: 'hello', proto: 1, client: 'probe' })
1545 .then(() => '', (e) => (e && e.message) || 'rejected');
1546 DaimondHand.close();
1547 return await p;
1548 }).catch(() => ''),
1549 allow(),
1550 ]);
1551 return typeof r[0] === 'string' && r[0].length > 20;
1552 });
1553
1554// ── The wasm ones ───────────────────────────────────────────────
1555
1556/// One wasm break: load the broken package, run the check, load the whole one,
1557/// run it again. Both are `--dev` builds, so the only difference is the patch.
1558async function provedWasm(name, pkg, checkName) {
1559 await proved(`${name} (wasm)`,
1560 async () => { usingPkg = pkg; await reload(); },
1561 () => reqCheck(checkName),
1562 async () => { usingPkg = 'whole'; await reload(); });
1563}
1564
1565await provedWasm('no TERM is sent', 'w-term',
1566 'sends no TERM, which the hand sets and refuses a caller for naming');
1567await provedWasm('a hand that cannot fence is refused', 'w-nofence',
1568 'refuses a hand that cannot fence a program');
1569await provedWasm('a hand with no granted root is refused', 'w-noroot',
1570 'refuses a hand that did not say which folder it was granted');
1571await provedWasm('an unpaired hand is refused in its own words', 'w-nopair',
1572 'refuses when no hand is paired, in the relay\'s own words');
1573await provedWasm('a cwd outside the Diamond is refused', 'w-nocwd',
1574 'refuses a working directory outside what the Diamond may touch');
1575await provedWasm('a tainted session loses the network', 'w-tainted',
1576 'a tainted session loses the network');
1577await provedWasm('a toolkit is never inferred from argv', 'w-argvkit',
1578 'a toolkit comes from what the user granted, never from argv');
1579// The defect that shipped, put back in the file it shipped from. `w-storefence`
1580// is the fence mapping a Diamond's own directory onto the disk again, which is
1581// what the hand refused the whole spec over; `w-storecwd` is a terminal taking
1582// the panel's store `cwd` literally and asking for a session outside its own
1583// fence. Both of them made every Diamond terminal refuse, and this file was
1584// green through all of it.
1585await provedWasm('the fence never names Daimond\'s own storage', 'w-storefence',
1586 'names no path inside Daimond\'s own storage, which is not on this disk');
1587await provedWasm('a session starts where the Diamond actually is', 'w-storecwd',
1588 'starts the session in the attached folder, though the panel asked for the Diamond');
1589
1590usingPkg = '';
1591breaking = '';
1592await reload();
1593
1594// ── Done ────────────────────────────────────────────────────────
1595
1596console.log(`\n${ok.length} ok, ${bad.length} failed, `
1597 + `${provedNames.length} properties proved against broken code.`);
1598if (bad.length) console.log(`failed:\n ${bad.join('\n ')}`);
1599await b.close();
1600server.close();
1601fs.rmSync(WORK, { recursive: true, force: true });
1602process.exit(bad.length ? 1 : (process.exitCode || 0));