Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_hand.mjs

39.7 KiB, 1 run

created by r2519314175:465, 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_hand.mjs — the machine hand's relay, end to end, without the Rust
2// binary.
3//
4// The relay's whole job is the part a correct hand never exercises: output that
5// arrives in order and stays attributable, a gap that is announced rather than
6// hidden, and the several ways a native messaging host can vanish. So this runs
7// against hand/install/mock_host.py, which speaks the real framing and the real
8// messages and can be told to misbehave on purpose.
9//
10// It launches a real Chrome with the real unpacked extension, writes the host
11// manifest into the test profile's own NativeMessagingHosts directory — which is
12// exactly where a browser started with --user-data-dir looks — and drives the
13// relay from a page on an allowed origin. The grant window is clicked for real:
14// unlike a site approval, this one has no second Chrome prompt behind it, so the
15// whole flow is reachable from a test.
16//
17// It also drives the boundary itself, which is the half a working day never
18// touches: a hostile origin's probe, a grant that must not carry from one origin
19// to the next, the four shapes of exec this end refuses, and the wording the
20// grant window is only allowed to use when the machine can back it.
21//
22// Needs nothing else running, and takes the first free port from 8877 rather
23// than the dev server's, so it can be run beside one. Run it headed, under xvfb:
24// xvfb-run -a -s "-screen 0 1400x900x24" node dev/verify_hand.mjs
25import path from 'node:path';
26import os from 'node:os';
27import fs from 'node:fs';
28import http from 'node:http';
29import { pathToFileURL } from 'node:url';
30
31const PW = process.env.DAIMOND_PW
32 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
33const { chromium } = await import(pathToFileURL(PW).href);
34const CHROME = process.env.DAIMOND_CHROME
35 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
36
37import { fileURLToPath } from 'node:url';
38// Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever
39// `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed
40// run under `xvfb-run` still went to the compositor and opened a window on the owner's
41// desktop. Importing this strips the two variables from `process.env`, which is all a
42// launcher that spreads `process.env` needs. See dev/display.mjs.
43import './display.mjs';
44const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); // this checkout, not one developer's home
45const EXT = `${ROOT}/ext`;
46// The SHIPPED manifest names one origin and it is not this test server. The dev
47// origins live in the generated build alone, so that the release artefact cannot
48// carry them -- see dev/extdev.mjs, and hand/REVIEW.md §1.6 for what they cost.
49const { extDev } = await import(pathToFileURL(`${ROOT}/dev/extdev.mjs`).href);
50const INSTALL = `${ROOT}/hand/install`;
51// Not /tmp -- see the SCRATCH note in harness.mjs.
52const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
53// The mock reads its configuration, and writes its log, BESIDE ITSELF. Two runs
54// of this file at once would each be told what to do by the other, which is not
55// a hypothetical: it happened, and it looked like the relay dropping chunks. So
56// each run gets its own copy of the mock and its own pair of files. The copy is
57// made on every launch, so a change to hand/install/mock_host.py is picked up.
58// The extension's reload grace, read from the file rather than repeated here: a
59// wait that disagreed with the hold would pass or fail for a reason that is not
60// the property. See ext/hand.js, "The reload grace".
61const HOLD_MS = (() => {
62 const src = fs.readFileSync(`${ROOT}/ext/hand.js`, 'utf8');
63 const m = /const HOLD_MS = (\d+);/.exec(src);
64 if (!m) { console.error('ext/hand.js no longer names HOLD_MS; the waits below cannot be aimed'); process.exit(2); }
65 return Number(m[1]);
66})();
67const MOCKDIR = path.join(SCRATCH, `verify-hand-mock-${process.pid}`);
68const MOCK = path.join(MOCKDIR, 'mock_host.py');
69const CFG = path.join(MOCKDIR, 'mock_cfg.json');
70const MOCKLOG = path.join(MOCKDIR, 'mock_host.log');
71fs.rmSync(MOCKDIR, { recursive: true, force: true });
72fs.mkdirSync(MOCKDIR, { recursive: true });
73fs.copyFileSync(`${INSTALL}/mock_host.py`, MOCK);
74fs.chmodSync(MOCK, 0o755);
75const PROFILE = path.join(SCRATCH, 'verify-hand');
76// The stub page only has to be on an origin the manifest lets speak to the
77// extension. It is not the app: nothing here needs the app -- and it is not the
78// dev server either, so the two must not fight over its port. The port is CHOSEN
79// below, from the first free one, and the dev build is then generated to trust
80// whichever that was. That is what lets this run beside `dev/serve.mjs`.
81const FIRST = Number(process.env.HAND_PORT || 8877);
82const EXTID = 'mpliijponglmmffjnonahhignkpkhmij';
83let PORT = FIRST;
84let HOSTILE_PORT = 0;
85let APP = '';
86let APP2 = '';
87let HOSTILE = '';
88let EXT_DEV = '';
89
90const ok = [], bad = [];
91const check = (name, pass, detail) => {
92 (pass ? ok : bad).push(name);
93 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
94};
95
96/// The page under test: it opens a port to the extension and keeps every
97/// message, in the order it arrived, for us to read afterwards.
98const PAGE = `<!doctype html><meta charset="utf-8"><title>hand</title>
99<body><h1>hand harness</h1><script>
100window.__seen = [];
101window.__port = null;
102window.__open = function () {
103 const id = document.documentElement.dataset.daimondHands;
104 if (!id) return 'no extension';
105 window.__seen = [];
106 window.__port = chrome.runtime.connect(id, { name: 'daimond-hand' });
107 window.__port.onMessage.addListener((m) => window.__seen.push(m));
108 window.__port.onDisconnect.addListener(() => window.__seen.push({ t: '__gone' }));
109 return 'ok';
110};
111// A port whose extension end has already gone throws here, and Chrome does
112// not tell the page it went until a turn later -- so a send that crosses that
113// moment is a race this harness has lost before (it died with "Attempting to
114// use a disconnected port object", killing the whole run). The answer is
115// reported rather than thrown: every check reads what the extension SENT, and
116// a post that could not be made is not one of them.
117window.__say = function (m) {
118 try { window.__port.postMessage(m); return 'sent'; }
119 catch (e) { return 'gone: ' + ((e && e.message) || e); }
120};
121// A well-formed exec, with anything overridden and anything dropped. The fence
122// is a real one: the hand refuses an empty fence and a cwd outside it, and so,
123// now, does the relay.
124window.__exec = function (id, argv, over, drop) {
125 const m = { t: 'exec', id, argv, cwd: '/tmp', env: [], stdin: null,
126 timeout_ms: 60000, capture: 'both',
127 fence: { rw: ['/tmp'], ro: [], deny: [], net: false } };
128 Object.assign(m, over || {});
129 for (const k of (drop || [])) delete m[k];
130 window.__say(m);
131};
132</script></body>`;
133
134/// The reviewer's probe: a bare hostile page that knows the extension id -- it is
135/// pinned in the manifest and public -- and simply asks. Everything it can try
136/// is tried, and what it got is left on the window.
137const PROBE = `<!doctype html><meta charset="utf-8"><title>probe</title>
138<body><h1>hostile</h1><script>
139window.__probe = { announced: null, runtime: null, connect: null, message: null };
140// What the page can see without touching anything, so the control can be run
141// from an allowed origin without opening a grant question nobody answers.
142window.__peek = function () {
143 return {
144 announced: document.documentElement.dataset.daimondHands || null,
145 runtime: (window.chrome && chrome.runtime && chrome.runtime.connect) ? 'present' : 'absent',
146 };
147};
148window.__try = function () {
149 const id = '${EXTID}';
150 window.__probe.announced = document.documentElement.dataset.daimondHands || null;
151 window.__probe.runtime = (window.chrome && chrome.runtime) ? 'present' : 'absent';
152 try {
153 const port = chrome.runtime.connect(id, { name: 'daimond-hand' });
154 window.__probe.connect = 'port';
155 port.onMessage.addListener((m) => { window.__probe.connect = 'answered:' + m.t; });
156 port.onDisconnect.addListener(() => { window.__probe.connect = 'disconnected'; });
157 port.postMessage({ t: 'hello', proto: 1, client: 'probe' });
158 } catch (e) {
159 window.__probe.connect = 'threw:' + ((e && e.message) || e);
160 }
161 try {
162 chrome.runtime.sendMessage(id, { cmd: 'ping' }, (r) => {
163 window.__probe.message = chrome.runtime.lastError
164 ? 'error:' + chrome.runtime.lastError.message : JSON.stringify(r);
165 });
166 } catch (e) {
167 window.__probe.message = 'threw:' + ((e && e.message) || e);
168 }
169 return window.__probe;
170};
171</script></body>`;
172
173/// Serves the harness page, and the hostile probe at `/probe` so the same probe
174/// can be run from an origin that IS allowed -- otherwise "it did not work"
175/// proves only that the probe does not work.
176async function serve(port, host) {
177 const s = http.createServer((req, res) => {
178 res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
179 res.end(/^\/probe/.test(req.url || '') ? PROBE : PAGE);
180 });
181 await new Promise((resolve, reject) => {
182 s.once('error', reject);
183 s.listen(port, host, resolve);
184 });
185 return s;
186}
187
188/// Takes the first free port from `from`, so a dev server that already holds one
189/// is left alone rather than fought over.
190async function serveFree(from) {
191 for (let port = from; port < from + 40; port++) {
192 try {
193 return { s: await serve(port, '127.0.0.1'), port };
194 } catch (e) {
195 if (e.code !== 'EADDRINUSE') { throw e; }
196 }
197 }
198 console.error(`No free port from ${from}. Set HAND_PORT.`);
199 process.exit(2);
200}
201
202const servers = [];
203const first = await serveFree(FIRST);
204servers.push(first.s);
205PORT = first.port;
206const second = await serveFree(PORT + 1);
207servers.push(second.s);
208HOSTILE_PORT = second.port;
209
210APP = `http://127.0.0.1:${PORT}`;
211// The SECOND allowed origin. Same server, same machine, different origin string
212// -- which is the whole of the per-origin grant: allowed once from 127.0.0.1,
213// the hand reached the host from localhost with no window shown at all.
214APP2 = `http://localhost:${PORT}`;
215// And one that is allowed nowhere. The reviewer's probe was a bare hostile HTML
216// file served from a port the extension trusted; this is the same file served
217// from one it does not.
218HOSTILE = `http://127.0.0.1:${HOSTILE_PORT}`;
219// The dev build trusts the port this run actually took. Nothing else about it
220// differs from the one a developer loads -- see dev/extdev.mjs.
221EXT_DEV = await extDev(PORT);
222
223// `localhost` is the other spelling of the same machine, and which family it
224// resolves to is the resolver's business, so both are answered.
225try { servers.push(await serve(PORT, '::1')); } catch (e) { /* no IPv6 loopback here */ }
226
227/// Points the profile's native messaging host at the mock, with the behaviour
228/// this part of the test needs. The host reads its configuration when it
229/// starts, so a fresh port picks up a fresh setting.
230const HOSTS = path.join(PROFILE, 'NativeMessagingHosts');
231function register(cfg) {
232 fs.mkdirSync(HOSTS, { recursive: true });
233 fs.writeFileSync(path.join(HOSTS, 'com.oxedyne.daimond.hand.json'), JSON.stringify({
234 name: 'com.oxedyne.daimond.hand',
235 description: 'Mock hand for verify_hand.mjs.',
236 path: MOCK,
237 type: 'stdio',
238 allowed_origins: ['chrome-extension://mpliijponglmmffjnonahhignkpkhmij/'],
239 }, null, '\t') + '\n');
240 fs.writeFileSync(CFG, JSON.stringify(cfg || {}, null, '\t') + '\n');
241 try { fs.rmSync(MOCKLOG); } catch (e) { /* first run */ }
242}
243function unregister() {
244 try { fs.rmSync(path.join(HOSTS, 'com.oxedyne.daimond.hand.json')); } catch (e) {}
245}
246
247fs.rmSync(PROFILE, { recursive: true, force: true });
248fs.mkdirSync(PROFILE, { recursive: true });
249// `caps` is what the hand claims it can enforce, and the grant window's
250// wording is chosen from it. `root:` is how the granted folder travels, since
251// wire.rs has no field for one.
252register({ chunks: 3, caps: ['fence:none', 'root:/tmp'] });
253
254const b = await chromium.launchPersistentContext(PROFILE, {
255 executablePath: CHROME,
256 headless: false,
257 args: ['--no-sandbox', '--disable-dev-shm-usage',
258 `--disable-extensions-except=${EXT_DEV}`, `--load-extension=${EXT_DEV}`],
259 viewport: { width: 1200, height: 800 },
260});
261
262const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
263
264async function waitSW() {
265 for (let i = 0; i < 100 && !b.serviceWorkers().length; i++) await sleep(100);
266 return b.serviceWorkers()[0];
267}
268
269/// Everything the page has been sent so far.
270async function seen(page) {
271 return await page.evaluate(() => window.__seen);
272}
273
274/// Waits for a message of a given `t`, and returns the whole transcript.
275async function until(page, t, ms = 12000) {
276 const until = Date.now() + ms;
277 while (Date.now() < until) {
278 const all = await seen(page);
279 if (all.some((m) => m.t === t)) return all;
280 await sleep(150);
281 }
282 return await seen(page);
283}
284
285/// Finds the grant window, reads every word of it, and answers. It is the
286/// extension's own page, so a click here is a real click, and for this question
287/// there is no second Chrome prompt behind it — this window IS the approval.
288///
289/// The whole window is returned, not only its heading: what it PROMISES is the
290/// thing under test, and the promise is in the body and the small print.
291async function handWindow(answer = 'allow', ms = 8000) {
292 const until = Date.now() + ms;
293 while (Date.now() < until) {
294 for (const p of b.pages()) {
295 if (/grant\.html/.test(p.url())) {
296 await p.waitForLoadState('domcontentloaded');
297 await sleep(300);
298 const said = await p.evaluate(() => {
299 const at = (id) => {
300 const n = document.getElementById(id);
301 return n && !n.hidden ? (n.textContent || '') : '';
302 };
303 return { head: at('head'), host: at('host'), scope: at('scope'), body: at('body'), fine: at('fine') };
304 });
305 said.url = p.url();
306 await p.click(answer === 'allow' ? '#allow' : '#deny');
307 return said;
308 }
309 }
310 await sleep(150);
311 }
312 return null;
313}
314
315/// The grant window as it would be drawn for a hand reporting `caps`, without
316/// waiting for a machine that reports them. Chrome's own page, opened directly,
317/// which is how dev/verify_ext_i18n.mjs reads this window too.
318async function wordingFor(caps) {
319 const p = await b.newPage();
320 await p.goto(`chrome-extension://${extId}/grant.html?nonce=probe&kind=hand`
321 + `&origin=${encodeURIComponent(APP)}&caps=${encodeURIComponent(caps)}`,
322 { waitUntil: 'domcontentloaded' });
323 await sleep(400);
324 const said = await p.evaluate(() => ({
325 body: (document.getElementById('body') || {}).textContent || '',
326 fine: (document.getElementById('fine') || {}).textContent || '',
327 scope: (document.getElementById('scope') || {}).textContent || '',
328 }));
329 await p.close();
330 return said;
331}
332
333/// Everything the page has been told, with the refusals easy to find.
334async function refusalFor(page, id) {
335 const all = await seen(page);
336 return all.find((m) => m.t === 'refused' && m.id === id) || null;
337}
338
339/// The extension's own popup, opened as a page. Messages to the broker have to
340/// come from a page like this one: Chrome does not deliver a runtime message
341/// back to the context that sent it, so the service worker cannot ask itself.
342let extId = '';
343async function popup() {
344 const p = await b.newPage();
345 await p.goto(`chrome-extension://${extId}/popup.html`);
346 await sleep(700);
347 return p;
348}
349
350try {
351 const sw = await waitSW();
352 extId = sw ? new URL(sw.url()).host : '';
353 check('the broker service worker started', !!sw);
354 check('the manifest asks for nativeMessaging', !!sw
355 && (await sw.evaluate(() => chrome.runtime.getManifest().permissions.includes('nativeMessaging'))));
356 check('the relay is loaded into the worker', !!sw
357 && (await sw.evaluate(() => typeof globalThis.DaimondHand === 'object')));
358
359 const page = await b.newPage();
360 await page.goto(APP + '/', { waitUntil: 'domcontentloaded' });
361 await sleep(800);
362 check('the extension announced itself to the page',
363 await page.evaluate(() => !!document.documentElement.dataset.daimondHands));
364
365 // ── What ships ──────────────────────────────────────────────────
366 //
367 // The manifest under test here is the GENERATED one, which has the dev
368 // origins in it on purpose. The one that ships is the file in the tree, and
369 // the only thing that keeps a user safe from a stray server on 8777 is that
370 // it does not name it. So the file itself is read.
371 {
372 const man = JSON.parse(fs.readFileSync(`${EXT}/manifest.json`, 'utf8'));
373 const pats = [].concat(
374 (man.externally_connectable || {}).matches || [],
375 ...(man.content_scripts || []).map((cs) => cs.matches || []));
376 const loop = pats.filter((p) => /(127\.0\.0\.1|localhost|\[::1\]|0\.0\.0\.0)/.test(p));
377 check('the shipped manifest names no loopback origin', loop.length === 0, loop.join(', '));
378 check('and it still names the one origin that is real',
379 pats.every((p) => /^https:\/\/daimond\.oxedyne\.com\//.test(p)), pats.join(', '));
380 const dev = JSON.parse(fs.readFileSync(`${EXT_DEV}/manifest.json`, 'utf8'));
381 check('the dev build is the one that carries them',
382 (dev.externally_connectable.matches || []).filter((p) => /127\.0\.0\.1|localhost/.test(p)).length === 2,
383 JSON.stringify(dev.externally_connectable.matches));
384 }
385
386 // ── A hostile origin ────────────────────────────────────────────
387 //
388 // The reviewer's probe, from a port the extension does not trust. Chrome is
389 // the doorman and this is what it is for; the point of running it is that
390 // the same page, from an origin that IS trusted, gets straight through.
391 {
392 const eve = await b.newPage();
393 await eve.goto(HOSTILE + '/probe', { waitUntil: 'domcontentloaded' });
394 await sleep(500);
395 const got = await eve.evaluate(() => window.__try());
396 await sleep(900);
397 const after = await eve.evaluate(() => window.__probe);
398 check('a hostile origin is not content-scripted at all', !got.announced, String(got.announced));
399 check('a hostile origin cannot reach the extension at all',
400 after.runtime === 'absent' || /threw|disconnected/.test(String(after.connect)),
401 JSON.stringify(after));
402 check('and nothing answers its message either',
403 !after.message || /error|threw/.test(String(after.message)), String(after.message));
404 await eve.close();
405
406 const ours = await b.newPage();
407 await ours.goto(APP + '/probe', { waitUntil: 'domcontentloaded' });
408 await sleep(500);
409 const mineProbe = await ours.evaluate(() => window.__peek());
410 check('the same page on an allowed origin can see the extension, so the probe works',
411 mineProbe.runtime === 'present' && !!mineProbe.announced, JSON.stringify(mineProbe));
412 await ours.close();
413 }
414
415 // ── The second look at the boundary is a real one ───────────────
416 //
417 // `mayConnect` used to add each pattern's host with the port stripped off,
418 // so it would have accepted 8778 on the strength of a pattern naming 8777.
419 // Chrome honours the port, so nothing was exploitable through it — which is
420 // the trouble exactly: a re-check laxer than the first is load-bearing only
421 // on the day the first one changes, and on that day it fails open.
422 {
423 const answers = await sw.evaluate(([mine, next]) => ({
424 allowed: globalThis.DaimondHand.allowedOrigin({ origin: mine, url: mine + '/' }),
425 otherPort: globalThis.DaimondHand.allowedOrigin({ origin: next, url: next + '/' }),
426 otherHost: globalThis.DaimondHand.allowedOrigin({ origin: 'https://daimond.oxedyne.com.evil.test', url: 'https://daimond.oxedyne.com.evil.test/' }),
427 live: globalThis.DaimondHand.allowedOrigin({ origin: 'https://daimond.oxedyne.com', url: 'https://daimond.oxedyne.com/' }),
428 nothing: globalThis.DaimondHand.allowedOrigin({}),
429 }), [APP, HOSTILE]);
430 check('the relay\'s own check accepts the origins the manifest names',
431 answers.allowed === APP && answers.live === 'https://daimond.oxedyne.com', JSON.stringify(answers));
432 check('the port is part of the origin, so a neighbouring port is refused',
433 answers.otherPort === '', JSON.stringify(answers.otherPort));
434 check('a host that merely starts with ours is refused',
435 answers.otherHost === '' && answers.nothing === '', JSON.stringify(answers));
436 }
437
438 // ── The grant ───────────────────────────────────────────────────
439 check('the hand is not granted before it is asked',
440 !(await sw.evaluate(() => globalThis.DaimondHand.granted())));
441
442 await page.evaluate(() => window.__open());
443 await page.evaluate(() => window.__say({ t: 'hello', proto: 1, client: 'verify_hand' }));
444 const said = await handWindow();
445 const head = said && said.head;
446 check('opening the port asks the user, in the extension\'s own window', !!head, JSON.stringify(said));
447 check('the question is about this computer, not about a site',
448 /computer/i.test(head || ''), String(head));
449 check('the window names the page that asked, because only that page is answered',
450 !!said && said.host === APP, JSON.stringify(said && said.host));
451
452 // This hand reports `fence:none`, so the window must NOT promise the
453 // folders: the sentence is chosen from what the hand said, and this hand
454 // said it can enforce nothing.
455 check('the wording is chosen from what the hand said it can enforce',
456 !!said && /fence:none/.test(said.scope || ''), JSON.stringify(said && said.scope));
457 check('and a hand with no fence does not get the sentence about folders',
458 !!said && !/folders the workspace/.test(said.body || '')
459 && /cannot limit which files/.test(said.body || ''), JSON.stringify(said && said.body));
460
461 let all = await until(page, 'hello');
462 const hello = all.find((m) => m.t === 'hello');
463 check('the hand answers the handshake', !!hello, JSON.stringify(hello));
464 check('the answer is the host\'s own, relayed untouched',
465 !!hello && hello.proto === 1 && /mock/.test(hello.host || ''), JSON.stringify(hello));
466 check('the grant is now recorded',
467 await sw.evaluate(() => globalThis.DaimondHand.granted()));
468
469 check('the grant is recorded for the origin that asked, and only that one',
470 await sw.evaluate((o) => globalThis.DaimondHand.granted(o), APP)
471 && !(await sw.evaluate((o) => globalThis.DaimondHand.granted(o), APP2)),
472 `${APP} yes, ${APP2} no`);
473
474 // ── The grant does not carry to the origin next door ────────────
475 //
476 // The same machine, the same server, the same port, the other spelling of
477 // loopback. Granted from 127.0.0.1, `localhost` reached the host with no
478 // window shown at all: the grant was one boolean for the whole browser.
479 {
480 const other = await b.newPage();
481 await other.goto(APP2 + '/', { waitUntil: 'domcontentloaded' });
482 await sleep(700);
483 await other.evaluate(() => window.__open());
484 const asked = await handWindow('deny', 6000);
485 check('a second origin is asked again rather than inheriting the grant',
486 !!asked && /computer/i.test(asked.head || ''), JSON.stringify(asked && asked.head));
487 check('and the window names the origin that is actually asking',
488 !!asked && asked.host === APP2, JSON.stringify(asked && asked.host));
489 const told = await until(other, 'error', 6000);
490 check('declining it refuses that origin, in the sentence the daimon reads',
491 told.some((m) => m.t === 'error' && /declined/i.test(m.message || '')),
492 JSON.stringify(told.filter((m) => m.t === 'error')).slice(0, 160));
493 check('and the first origin still holds its own grant',
494 await sw.evaluate((o) => globalThis.DaimondHand.granted(o), APP)
495 && !(await sw.evaluate((o) => globalThis.DaimondHand.granted(o), APP2)));
496 await other.close();
497 }
498
499 // The popup is where a person goes to see what they have allowed and to take
500 // it back, so the machine hand has to be findable there beside the sites --
501 // in words, not as the sentinel the code passes around.
502 {
503 const pop = await popup();
504 const text = await pop.evaluate(() => document.body.innerText);
505 check('the popup lists it among what the user has allowed',
506 /commands on this computer/i.test(text), text.replace(/\n/g, ' / '));
507 check('and says which page it was allowed for', text.includes(APP), text.replace(/\n/g, ' / '));
508 check('and offers to revoke it', await pop.evaluate(() => {
509 const li = [...document.querySelectorAll('#granted li')]
510 .find((n) => /computer/i.test(n.textContent));
511 return !!(li && li.querySelector('button'));
512 }));
513 await pop.close();
514 }
515
516 // ── The wording is the machine's, not the product's ─────────────
517 //
518 // Release gate 1 in hand/README.md: the consent window's wording must be
519 // chosen from `caps` rather than hard-coded, so it can only claim what that
520 // machine actually enforces. A fenceless machine gets different, honest
521 // words, and the difference is in the sentence a user would act on.
522 {
523 const none = await wordingFor('fence:none');
524 const real = await wordingFor('fence:linux landlock:abi-8 journal');
525 const silent = await wordingFor('');
526 check('a fenceless machine is not described as fencing anything',
527 !/folders the workspace/.test(none.body) && /cannot limit which files/.test(none.body), none.body);
528 check('a machine that fences gets the sentence about folders',
529 /folders the workspace/.test(real.body), real.body);
530 check('the two are not the same words', none.body !== real.body);
531 check('a hand that keeps a journal is the only one that promises one',
532 /journal/i.test(real.fine) && !/journal/i.test(none.fine), `${real.fine} || ${none.fine}`);
533 check('what the machine can enforce is shown verbatim',
534 /landlock:abi-8/.test(real.scope) && /fence:none/.test(none.scope), `${real.scope} || ${none.scope}`);
535 check('a hand that said nothing is a third answer, not a promise',
536 /did not say/.test(silent.scope) && /did not say/.test(silent.body), `${silent.scope} || ${silent.body}`);
537 }
538
539 // ── Order and attribution ───────────────────────────────────────
540 await page.evaluate(() => window.__exec('r1', ['cargo', 'test']));
541 all = await until(page, 'ended');
542 const mine = all.filter((m) => m.id === 'r1');
543 const chunks = mine.filter((m) => m.t === 'chunk');
544 const outs = chunks.filter((m) => m.stream === 'out');
545 check('the run starts and ends', mine.some((m) => m.t === 'started') && mine.some((m) => m.t === 'ended'));
546 check('every chunk arrives as its own message, none joined', chunks.length === 4, `${chunks.length} chunks`);
547 check('the out stream is in order and complete',
548 outs.map((m) => m.seq).join(',') === '1,2,3', outs.map((m) => m.seq).join(','));
549 check('started comes before the first chunk, ended after the last',
550 mine.findIndex((m) => m.t === 'started') === 0 && mine[mine.length - 1].t === 'ended',
551 mine.map((m) => m.t).join(' '));
552 check('the chunks carry the text the host sent',
553 outs.map((m) => m.data).join('').includes('line 2 of cargo test'), '');
554 check('no gap is reported when there is none',
555 !mine.some((m) => m.t === 'error'), JSON.stringify(mine.filter((m) => m.t === 'error')));
556
557 // ── The page does not choose its own compartment ────────────────
558 //
559 // The fence arrived from the page and went to the hand verbatim, and an
560 // exec with no fence at all went too. A reviewer sent `fence:{rw:["/"]}`
561 // with its own LD_PRELOAD and the hand received it byte for byte. The hand
562 // is the authority and is being made to clamp; these are the shapes this end
563 // can be sure of, and each of them is refused before the host sees it.
564 {
565 // A fresh port, so the refusals are the only things on it.
566 await page.evaluate(() => { window.__say({ t: 'bye' }); window.__port.disconnect(); });
567 await sleep(400);
568 await page.evaluate(() => window.__open());
569 await sleep(300);
570
571 await page.evaluate(() => window.__exec('bad-nofence', ['cargo', 'test'], {}, ['fence']));
572 await page.evaluate(() => window.__exec('bad-root', ['cargo', 'test'],
573 { fence: { rw: ['/'], ro: [], deny: [], net: true } }));
574 await page.evaluate(() => window.__exec('bad-home', ['cargo', 'test'],
575 { fence: { rw: ['/home'], ro: [], deny: [], net: false } }));
576 await page.evaluate(() => window.__exec('bad-env', ['cargo', 'test'],
577 { env: [['LD_PRELOAD', '/tmp/evil.so']] }));
578 await page.evaluate(() => window.__exec('bad-cwd', ['cargo', 'test'],
579 { cwd: '/etc', fence: { rw: ['/tmp'], ro: [], deny: [], net: false } }));
580 await page.evaluate(() => window.__exec('bad-outside', ['cargo', 'test'],
581 { cwd: '/var/tmp', fence: { rw: ['/var/tmp'], ro: [], deny: [], net: false } }));
582 await page.evaluate(() => window.__exec('bad-timeout', ['cargo', 'test'], { timeout_ms: 0 }));
583 await page.evaluate(() => window.__exec('x'.repeat(400), ['cargo', 'test']));
584 await sleep(800);
585
586 const noFence = await refusalFor(page, 'bad-nofence');
587 const rootFence = await refusalFor(page, 'bad-root');
588 const homeFence = await refusalFor(page, 'bad-home');
589 const badEnv = await refusalFor(page, 'bad-env');
590 const badCwd = await refusalFor(page, 'bad-cwd');
591 const badTime = await refusalFor(page, 'bad-timeout');
592 check('an exec with no fence at all is refused',
593 !!noFence && /fence/i.test(noFence.reason), JSON.stringify(noFence));
594 check('a fence naming the whole filesystem is refused',
595 !!rootFence && /machine/i.test(rootFence.reason), JSON.stringify(rootFence));
596 check('and so is one naming a folder the machine follows from',
597 !!homeFence, JSON.stringify(homeFence));
598 check('an LD_PRELOAD in the environment is refused',
599 !!badEnv && /LD_PRELOAD/.test(badEnv.reason), JSON.stringify(badEnv));
600 check('a working directory outside the fence is refused',
601 !!badCwd && /outside/i.test(badCwd.reason), JSON.stringify(badCwd));
602 check('a command with no wall-clock limit is refused',
603 !!badTime && /timeout_ms/.test(badTime.reason), JSON.stringify(badTime));
604 // The hand said `root:/tmp` in its hello, which is the folder its grant
605 // covers. A fence outside that is a fence the grant does not reach, and
606 // this end holds the page to it as well.
607 const outside = await refusalFor(page, 'bad-outside');
608 check('a fence outside the folder the hand says it was granted is refused',
609 !!outside && /outside/.test(outside.reason) && /tmp/.test(outside.reason),
610 JSON.stringify(outside));
611 const seenAll = await seen(page);
612 const longId = seenAll.find((m) => m.t === 'refused' && m.id.length > 300);
613 check('an unbounded id is refused, because every answer carries it',
614 !!longId && /128/.test(longId.reason), JSON.stringify(longId && longId.reason));
615 check('none of them reached the host', await (async () => {
616 const log = fs.existsSync(MOCKLOG) ? fs.readFileSync(MOCKLOG, 'utf8') : '';
617 return !/bad-nofence|bad-root|bad-env|LD_PRELOAD/.test(log);
618 })(), 'the mock host logs everything it is sent');
619
620 // ── A TERMINAL may be fenced wider than a command ──────────────
621 //
622 // The hand offers `terminal-ceiling:` folders in its own hello, and this end lets a
623 // TERMINAL be fenced to one of them. Both halves are checked, because letting the
624 // wider folder through for everything would pass the first on its own -- and that is
625 // the difference between a terminal being the user and the fence being a formality.
626 //
627 // `/var/tmp` is the same folder the exec check above is refused for, so the two
628 // answers are about the same path and differ only in which door asked.
629 register({ chunks: 1, caps: ['fence:linux', 'root:/tmp', 'terminal-ceiling:/var/tmp'] });
630 await page.evaluate(() => window.__open());
631 await page.evaluate(() => window.__say({ t: 'hello', proto: 2, client: 'harness' }));
632 await sleep(300);
633 await page.evaluate(() => window.__say({
634 t: 'open', id: 'term-wide', argv: ['/bin/bash'], cwd: '/var/tmp',
635 env: [], size: { cols: 80, rows: 24 },
636 fence: { rw: ['/var/tmp'], ro: [], deny: [], net: false },
637 }));
638 await page.evaluate(() => window.__say({
639 t: 'exec', id: 'cmd-wide', argv: ['/bin/echo', 'hi'], cwd: '/var/tmp',
640 env: [], timeout_ms: 5000, capture: 'both',
641 fence: { rw: ['/var/tmp'], ro: [], deny: [], net: false },
642 }));
643 await sleep(600);
644 const termWide = await refusalFor(page, 'term-wide');
645 const cmdWide = await refusalFor(page, 'cmd-wide');
646 check('a TERMINAL may be fenced to a folder the machine offered as a ceiling',
647 !termWide, JSON.stringify(termWide && termWide.reason).slice(0, 160));
648 check('and a COMMAND with the same fence is still refused, which is the whole difference',
649 !!cmdWide && /outside/.test(cmdWide.reason),
650 JSON.stringify(cmdWide && cmdWide.reason).slice(0, 160));
651
652 // And a well-formed one still runs, so none of the above is a blanket no.
653 await page.evaluate(() => window.__exec('good', ['cargo', 'test']));
654 const done = await until(page, 'ended');
655 check('a well-formed exec is still forwarded and still runs',
656 done.some((m) => m.t === 'ended' && m.id === 'good'),
657 JSON.stringify(done.filter((m) => m.t === 'refused')).slice(0, 200));
658 }
659
660 // ── A gap is announced, not hidden ──────────────────────────────
661 register({ chunks: 3, gap: true });
662 await page.evaluate(() => { window.__say({ t: 'bye' }); window.__port.disconnect(); });
663 await sleep(400);
664 await page.evaluate(() => window.__open());
665 await page.evaluate(() => window.__exec('r2', ['make']));
666 all = await until(page, 'ended');
667 const gapErr = all.find((m) => m.t === 'error' && /missing|hole|sequence/i.test(m.message || ''));
668 check('a hole in the sequence is reported', !!gapErr, JSON.stringify(gapErr));
669 check('the report names the run and the stream it belongs to',
670 !!gapErr && gapErr.id === 'r2' && /out/.test(gapErr.message), JSON.stringify(gapErr));
671 const iErr = all.indexOf(gapErr);
672 const iChunk = all.findIndex((m) => m.t === 'chunk' && m.seq === 3);
673 check('it arrives before the chunk that revealed it', iErr >= 0 && iErr < iChunk, `${iErr} < ${iChunk}`);
674 check('the chunks are still forwarded, hole and all',
675 all.filter((m) => m.t === 'chunk' && m.stream === 'out').length === 3);
676
677 // ── Over Chrome's 1 MB cap ──────────────────────────────────────
678 register({ huge: true });
679 await page.evaluate(() => { window.__say({ t: 'bye' }); window.__port.disconnect(); });
680 await sleep(400);
681 await page.evaluate(() => window.__open());
682 await page.evaluate(() => window.__exec('r3', ['dump']));
683 all = await until(page, 'ended');
684 const capErr = all.find((m) => m.t === 'error' && /1 MB|disconnected/i.test(m.message || ''));
685 check('an oversized message is reported, not swallowed', !!capErr, JSON.stringify(capErr));
686 check('the report names the 1 MB limit as a cause',
687 !!capErr && /1 MB/.test(capErr.message), (capErr || {}).message);
688 check('the run is closed out so the page is not left waiting',
689 all.some((m) => m.t === 'ended' && m.id === 'r3' && m.exit === -1),
690 JSON.stringify(all.filter((m) => m.t === 'ended')));
691
692 // ── A host that dies mid-command ────────────────────────────────
693 register({ crash: true });
694 await page.evaluate(() => { window.__say({ t: 'bye' }); window.__port.disconnect(); });
695 await sleep(400);
696 await page.evaluate(() => window.__open());
697 await page.evaluate(() => window.__exec('r4', ['boom']));
698 all = await until(page, 'ended');
699 check('a crash mid-command is reported',
700 all.some((m) => m.t === 'error' && /disconnected/i.test(m.message || '')),
701 JSON.stringify(all.filter((m) => m.t === 'error')));
702 check('and its run is closed out too',
703 all.some((m) => m.t === 'ended' && m.id === 'r4' && m.killed === true));
704
705 // ── The page goes away with a command running ───────────────────
706 register({ chunks: 3, delay_ms: 900 });
707 const runner = await b.newPage();
708 await runner.goto(APP + '/', { waitUntil: 'domcontentloaded' });
709 await sleep(500);
710 await runner.evaluate(() => window.__open());
711 await runner.evaluate(() => window.__exec('r5', ['sleep']));
712 const running = await until(runner, 'started', 6000);
713 const pid = (running.find((m) => m.t === 'started') || {}).pid;
714 check('the run is really a process on this machine', !!pid && fs.existsSync(`/proc/${pid}`), String(pid));
715 await runner.close();
716 // SINCE 2026-08-25 THERE ARE TWO FACTS HERE AND NOT ONE. A page that vanishes
717 // is held for the length of the grace, because the commonest way a page
718 // vanishes is a reload; what it left is stopped only when nothing comes back
719 // for it. So "no orphan" is still the promise and "at once" is no longer part
720 // of it, and BOTH halves are asserted -- a file that only waited long enough
721 // would pass with the grace deleted, and one that only checked the hold would
722 // pass with the teardown deleted.
723 await sleep(3000);
724 check('a page that goes away leaves what it was running alive for the grace',
725 fs.existsSync(`/proc/${pid}`), `pid ${pid}`);
726 // The host dies when the hold runs out and its port is closed. Poll rather
727 // than guess: the mock is mid-sleep and only notices its stdin has gone when
728 // it comes back up.
729 let alive = true;
730 for (let i = 0; i < (HOLD_MS + 20000) / 200 && alive; i++) {
731 await sleep(200);
732 alive = fs.existsSync(`/proc/${pid}`);
733 }
734 check('and when nothing comes back for it, leaves no orphan behind', !alive, `pid ${pid}`);
735
736 // And it says so rather than merely dropping the pipe. A host that is
737 // between commands is sitting in a read, which is where a `bye` can actually
738 // be seen -- the one above was mid-run and was reaped before it looked.
739 register({ chunks: 1 });
740 const idle = await b.newPage();
741 await idle.goto(APP + '/', { waitUntil: 'domcontentloaded' });
742 await sleep(500);
743 await idle.evaluate(() => window.__open());
744 await idle.evaluate(() => window.__say({ t: 'hello', proto: 1, client: 'verify_hand' }));
745 await until(idle, 'hello', 6000);
746 await idle.close();
747 let log = '';
748 for (let i = 0; i < (HOLD_MS + 20000) / 200; i++) {
749 await sleep(200);
750 log = fs.existsSync(MOCKLOG) ? fs.readFileSync(MOCKLOG, 'utf8') : '';
751 if (/"bye"/.test(log)) break;
752 }
753 check('the relay says bye on the way out, once the hold has run out', /"bye"/.test(log),
754 log.split('\n').slice(-3).join(' | '));
755
756 // ── The host is not installed ───────────────────────────────────
757 unregister();
758 await page.evaluate(() => { try { window.__say({ t: 'bye' }); window.__port.disconnect(); } catch (e) {} });
759 await sleep(400);
760 await page.evaluate(() => window.__open());
761 await page.evaluate(() => window.__say({ t: 'hello', proto: 1, client: 'verify_hand' }));
762 all = await until(page, 'error');
763 const gone = all.find((m) => m.t === 'error');
764 check('a missing host is reported as such', !!gone, JSON.stringify(gone));
765 check('and the sentence says exactly what to install',
766 !!gone && /install\.sh/.test(gone.message) && /com\.oxedyne\.daimond\.hand/.test(gone.message),
767 (gone || {}).message);
768 check('it does not merely repeat Chrome\'s own wording',
769 !!gone && /cargo build|README/.test(gone.message), '');
770
771 // ── Revocation ──────────────────────────────────────────────────
772 //
773 // Clicked, not messaged. The Revoke button beside the machine hand is the
774 // one a person would press, and it is a plain button in a plain list.
775 {
776 const pop = await popup();
777 await pop.evaluate(() => {
778 const li = [...document.querySelectorAll('#granted li')]
779 .find((n) => /computer/i.test(n.textContent));
780 if (li) li.querySelector('button').click();
781 });
782 await sleep(700);
783 check('revoking from the popup takes the grant back',
784 !(await sw.evaluate(() => globalThis.DaimondHand.granted())));
785 const text = await pop.evaluate(() => document.body.innerText);
786 check('and the popup no longer lists it', !/commands on this computer/i.test(text),
787 text.replace(/\n/g, ' / '));
788 await pop.close();
789 }
790} finally {
791 await b.close().catch(() => {});
792 for (const s of servers) s.close();
793 try { fs.rmSync(MOCKDIR, { recursive: true, force: true }); } catch (e) {}
794}
795
796console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
797process.exit(bad.length ? 1 : 0);