Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_handstop.mjs

20.0 KiB, 1 run

created by r2519314175:475, 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_handstop.mjs — a daimon can stop what it started, through the real chain.
2//
3// WHY THIS FILE EXISTS. On 2026-08-23 a daimon started two servers in front of
4// the owner and could not stop either. That was not one defect but four in a
5// line, and three of them were still there after the first was fixed:
6//
7// the hand kept no record of a group a finished run left standing, and
8// scoped signals to the Landlock domain that sent them, so a LATER
9// command's `kill` answered "Operation not permitted". FIXED 2026-08-23
10// in `hand/`: `Runner.left`, `Req::Runs`, `Req::Signal` by identifier.
11// ext/hand.js default-DENIED `{"t":"runs"}` at its message switch, so the
12// question never left the browser.
13// www/js/hand.js dropped the answer for having no `id` — and it has no id
14// by design, because a filter on that message would be a selector.
15// src/tools.rs offered no daimon-facing tool that could send either.
16//
17// Each of the three was invisible to every existing test, because each sits in a
18// different file and none of them is reached by the one below it. `hand/`'s own
19// unit tests drive the runner in process and never meet the browser;
20// `dev/verify_hand.mjs` drives the extension from a stub page that speaks the
21// port directly and never loads `www/js/hand.js`; and the Rust tests cannot
22// reach a wasm-only call site at all. So this file is the join: REAL Chrome, the
23// REAL extension, the REAL `www/js/hand.js`, and the REAL `daimond-hand` binary
24// with the REAL Landlock fence, and it asks the one question none of them can —
25// does a command that outlives itself come back?
26//
27// WHAT IT DOES NOT PROVE. There is no app and no model here: the page calls
28// `DaimondHand.runs()` where `Tool::runs` would. What the model is HANDED is
29// decided by `runs_step` and `runs_report`, which are pure and tested natively
30// in `src/tools.rs`. This file proves the transport those two sit on top of.
31//
32// THE PROOF THAT A LEAK IS A LEAK. A `sleep` is started in the background by a
33// command that then exits, so the run ENDS and its process group does not. The
34// pid is read out of the command's own output and checked FROM NODE, outside the
35// browser and outside the fence, at every step: it is alive after the run has
36// ended, and it is gone after the stop. A listing that agreed with itself would
37// prove nothing; the kernel is the oracle.
38//
39// xvfb-run -a -s "-screen 0 1400x900x24" node dev/verify_handstop.mjs
40//
41// --break denyruns put the extension's default-deny back (ext/hand.js)
42// --break dropruns put the page's drop-for-no-id back (www/js/hand.js)
43// --keep leave the scratch tree behind
44//
45// Both breaks are the code as it stood at commit 3e9ac52, restored into a COPY of
46// the file and never into the tree. Each names the checks it must redden, and a
47// break that reddens none of them fails the run: a check that has never been seen
48// failing is not a check.
49//
50// Headed, because Chromium loads an unpacked extension in no other mode. Needs
51// nothing else running: it serves its own two files.
52import fs from 'node:fs';
53import os from 'node:os';
54import http from 'node:http';
55import path from 'node:path';
56import { spawnSync } from 'node:child_process';
57import { fileURLToPath, pathToFileURL } from 'node:url';
58
59// Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever
60// `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed
61// run under `xvfb-run` still went to the compositor and opened a window on the owner's
62// desktop. Importing this strips the two variables from `process.env`, which is all a
63// launcher that spreads `process.env` needs. See dev/display.mjs.
64import './display.mjs';
65const PW = process.env.DAIMOND_PW
66 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
67const { chromium } = await import(pathToFileURL(PW).href);
68const CHROME = process.env.DAIMOND_CHROME
69 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
70
71const HERE = path.dirname(fileURLToPath(import.meta.url));
72const ROOT = path.join(HERE, '..');
73const WWW = path.join(ROOT, 'www');
74const INSTALL = path.join(ROOT, 'hand/install/install.sh');
75const HAND = path.join(ROOT, 'hand/target/release/daimond-hand');
76const EXTID = 'mpliijponglmmffjnonahhignkpkhmij';
77// Not /tmp -- it is a tmpfs, and what is written there is RAM charged to this
78// machine's agent fleet. See the SCRATCH note in harness.mjs.
79const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
80
81const argv = process.argv.slice(2);
82const KEEP = argv.includes('--keep');
83const BREAK = (() => { const i = argv.indexOf('--break'); return i >= 0 ? String(argv[i + 1] || '') : ''; })();
84
85// Each break is the line as it stood before this work, put back in a copy. The
86// `must` list is what it has to redden; a break that reddens none of them has
87// proved that the checks aimed at it are measuring nothing.
88const BREAKS = {
89 // ext/hand.js:783 — `runs` reached the default arm, which refuses.
90 denyruns: {
91 file: 'ext',
92 find: "\t\t\tcase 'runs':\n\t\t\t\tbreak;",
93 with: "\t\t\tcase '__runs_never':\n\t\t\t\tbreak;",
94 must: ['the hand answers what it is running', 'a background process is listed as standing'],
95 },
96 // www/js/hand.js:360 — the answer carries no id, so the run switch dropped it.
97 dropruns: {
98 file: 'www',
99 find: "\t\tif (msg.t === 'runs') {",
100 with: "\t\tif (false && msg.t === 'runs') {",
101 must: ['the hand answers what it is running', 'a background process is listed as standing'],
102 },
103};
104if (BREAK && !BREAKS[BREAK]) {
105 console.error(`unknown break '${BREAK}'; there are: ${Object.keys(BREAKS).join(', ')}`);
106 process.exit(2);
107}
108
109const ok = [], bad = [];
110const check = (name, pass, detail) => {
111 (pass ? ok : bad).push(name);
112 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
113};
114const note = (s) => console.log(' · ' + s);
115const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
116
117/// Is this pid a process that is still going, asked of the kernel and not of the
118/// hand? A zombie does not count: a reaped child keeps its `/proc` entry for a
119/// moment and counting it would make every successful stop look like a failure.
120function alive(pid) {
121 if (!pid || !Number.isInteger(pid)) return false;
122 try {
123 const st = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
124 return st.slice(st.lastIndexOf(')') + 2).split(' ')[0] !== 'Z';
125 } catch (e) { return false; }
126}
127
128// ── One tree, and nothing of this run survives it ───────────────────
129const BASE = path.join(SCRATCH, `handstop-${process.pid}`);
130const PROFILE = path.join(BASE, 'profile');
131const JOURNAL = path.join(BASE, 'journal');
132const GRANT = path.join(BASE, 'work');
133const HOSTS = path.join(PROFILE, 'NativeMessagingHosts');
134fs.rmSync(BASE, { recursive: true, force: true });
135for (const d of [PROFILE, JOURNAL, GRANT, HOSTS]) fs.mkdirSync(d, { recursive: true });
136// 0700, and it is load-bearing: `root.txt` beside the journal makes
137// `journal::is_ours` answer no, so the hand will not tighten the directory
138// itself and a mode anyone else can read is a refusal to start.
139fs.chmodSync(JOURNAL, 0o700);
140
141// ── The extension, and the page, as this run will serve them ────────
142//
143// The dev build is the only one that will speak to a page on loopback. It is
144// written to a shared directory, so it is COPIED here before a break touches it:
145// patching the shared one would damage every other harness on this machine.
146const { extDev } = await import(pathToFileURL(path.join(HERE, 'extdev.mjs')).href);
147const PORT = await (async () => {
148 for (let p = 8837; p < 8877; p++) {
149 try {
150 await new Promise((res, rej) => {
151 const s = http.createServer(() => {});
152 s.once('error', rej);
153 s.listen(p, '127.0.0.1', () => s.close(res));
154 });
155 return p;
156 } catch (e) { if (e.code !== 'EADDRINUSE') throw e; }
157 }
158 console.error('no free port from 8837');
159 process.exit(2);
160})();
161const SHARED_EXT = await extDev(PORT);
162const EXT = path.join(BASE, 'ext');
163fs.cpSync(SHARED_EXT, EXT, { recursive: true });
164
165/// Puts one break back into a copy of the file it was in, and refuses where the
166/// line it names is not there — a break whose anchor has moved damages nothing
167/// and passes quietly, which is worse than not running at all.
168function damage(text, name) {
169 const b = BREAKS[name];
170 if (!text.includes(b.find)) {
171 console.error(`\nbreak '${name}' cannot be applied: its anchor is not in the file.\n${b.find}`);
172 process.exit(2);
173 }
174 return text.replace(b.find, b.with);
175}
176if (BREAK && BREAKS[BREAK].file === 'ext') {
177 const f = path.join(EXT, 'hand.js');
178 fs.writeFileSync(f, damage(fs.readFileSync(f, 'utf8'), BREAK));
179 note(`break '${BREAK}' applied to the extension copy`);
180}
181
182const PAGE = `<!doctype html><meta charset="utf-8"><title>handstop</title>
183<body><h1>handstop</h1>
184<script src="/js/hand.js"></script>
185<script>
186window.__why = function (e) { return 'ERR ' + ((e && e.message) || e); };
187window.__status = function () { return DaimondHand.status().then(function (s) { return String(s); }, window.__why); };
188window.__runs = function () { return DaimondHand.runs().then(function (s) { return JSON.stringify(s); }, window.__why); };
189window.__signal = function (id, sig) { return DaimondHand.signal(id, sig).then(function () { return 'sent'; }, window.__why); };
190window.__run = function (spec) { return DaimondHand.run(JSON.stringify(spec)).then(function (r) { return r; }, window.__why); };
191</script></body>`;
192
193const server = http.createServer((req, res) => {
194 if (/^\/js\/hand\.js/.test(req.url || '')) {
195 let js = fs.readFileSync(path.join(WWW, 'js/hand.js'), 'utf8');
196 if (BREAK && BREAKS[BREAK].file === 'www') js = damage(js, BREAK);
197 res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
198 res.end(js);
199 return;
200 }
201 res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
202 res.end(PAGE);
203});
204await new Promise((r) => server.listen(PORT, '127.0.0.1', r));
205if (BREAK && BREAKS[BREAK].file === 'www') note(`break '${BREAK}' applied to the page's copy of hand.js`);
206
207// ── The hand: built, granted a folder, and registered ───────────────
208//
209// `CARGO_TARGET_DIR` is removed from the build's environment, and that is not
210// tidying: an agent working in this tree usually has one set, cargo would write
211// the binary there, and `HAND` — the path `install.sh` registers and this file
212// therefore runs — would still be whatever was built last. See the same note in
213// verify_handreal.mjs, where an inherited one made a security test pass against a
214// binary from before the fix.
215const buildEnv = { ...process.env };
216delete buildEnv.CARGO_TARGET_DIR;
217if (!process.env.DAIMOND_NO_BUILD) {
218 const r = spawnSync('cargo', ['build', '--release', '--manifest-path', 'hand/Cargo.toml'],
219 { cwd: ROOT, encoding: 'utf8', env: buildEnv });
220 if (r.status !== 0) {
221 console.log((r.stderr || '').split('\n').filter((l) => /^error/.test(l)).slice(0, 5).join('\n'));
222 }
223}
224check('the hand is built', fs.existsSync(HAND), HAND);
225if (!fs.existsSync(HAND)) { console.log(`\n${ok.length} ok, ${bad.length} failed`); process.exit(1); }
226
227// The granted root, named the way a browser-launched hand actually reads it: a
228// line in `root.txt` beside the journal. Chrome hands a native messaging host
229// its OWN environment, so a variable exported in a terminal is not there.
230fs.writeFileSync(path.join(JOURNAL, 'root.txt'),
231 `# The one folder Daimond's machine hand may work in.\n${GRANT}\n`);
232process.env.DAIMOND_HAND_JOURNAL_DIR = JOURNAL;
233delete process.env.DAIMOND_HAND_ROOT;
234
235const inst = spawnSync('bash', [INSTALL, '--dir', HOSTS, HAND], { cwd: ROOT, encoding: 'utf8' });
236check('install.sh registers the real binary in this run\'s own profile',
237 inst.status === 0 && fs.existsSync(path.join(HOSTS, 'com.oxedyne.daimond.hand.json')),
238 (inst.stderr || inst.stdout || '').trim().split('\n').slice(-2).join(' '));
239
240// ── The browser ─────────────────────────────────────────────────────
241const b = await chromium.launchPersistentContext(PROFILE, {
242 executablePath: CHROME,
243 headless: false,
244 args: ['--no-sandbox', '--disable-dev-shm-usage',
245 `--disable-extensions-except=${EXT}`, `--load-extension=${EXT}`],
246 viewport: { width: 1100, height: 700 },
247});
248const page = await b.newPage();
249await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'domcontentloaded' });
250await sleep(500);
251
252/// Finds the grant window and answers it. It is the extension's own page, so the
253/// click is a real one and there is no second Chrome prompt behind it.
254async function grant(answer = 'allow', ms = 15000) {
255 const until = Date.now() + ms;
256 while (Date.now() < until) {
257 for (const p of b.pages()) {
258 if (/grant\.html/.test(p.url())) {
259 await p.waitForLoadState('domcontentloaded');
260 await sleep(250);
261 await p.click(answer === 'allow' ? '#allow' : '#deny');
262 return true;
263 }
264 }
265 await sleep(150);
266 }
267 return false;
268}
269
270const found = await page.evaluate(() => document.documentElement.dataset.daimondHands || '');
271check('the page finds the extension the way the app finds it', found === EXTID, found || '(no stamp)');
272
273// The first message is what raises the grant window, so the answer is armed
274// before it is sent and awaited after.
275const statusP = page.evaluate(() => window.__status());
276const granted = await grant('allow');
277check('the grant window opened and was answered', granted);
278const status = JSON.parse((await statusP) || '{}');
279check('the real hand answered and named the folder it was granted',
280 status && status.transport === 'machine' && status.root === GRANT,
281 JSON.stringify(status).slice(0, 300));
282// `paired` is FALSE here and that is correct, not a fault: `hand/REVIEW.md` §1.14
283// refuses a command where the page cannot prove that the folder it has open is
284// the folder the hand was granted, and a stub page holds no folder at all. It is
285// asserted rather than worked around, because everything below happens IN SPITE
286// of it -- `Tool::runs` deliberately does not gate on the folder proof, since it
287// runs nothing and reads nothing, and gating on it would make a leaked server
288// unstoppable on exactly the workspaces where one is easiest to leak.
289check('the folder proof refuses this page, and stopping a run works regardless',
290 status && status.paired === false && /folder|workspace/i.test(String(status.reason || '')),
291 JSON.stringify(status.reason || '(no reason)').slice(0, 200));
292// The hand's own home, which is how a granted toolchain's roots are expressed and
293// how the extension recognises one. Without it a CORRECTLY granted toolchain is
294// refused too -- a second, independent way to be told a fence reaches outside the
295// grant, and one that would not show in the sentence the first produces.
296check('the hand reports the home a granted toolchain would sit in',
297 (status.caps || []).some((c) => typeof c === 'string' && c.indexOf('home:/') === 0),
298 JSON.stringify(status.caps || []).slice(0, 300));
299
300// ── 1. THE QUESTION ITSELF REACHES THE HAND ─────────────────────────
301//
302// Red against the extension's default-deny AND against the page's drop, and it
303// is the cheapest of the three checks that are: nothing has been started yet, so
304// what it measures is only whether the message can make the round trip at all.
305const idle = await page.evaluate(() => window.__runs());
306check('the hand answers what it is running',
307 !String(idle).startsWith('ERR') && Array.isArray(JSON.parse(idle || '{}').runs),
308 String(idle).slice(0, 300));
309check('and it is running nothing before anything is started',
310 (() => { try { return JSON.parse(idle).runs.length === 0; } catch (e) { return false; } })(),
311 String(idle).slice(0, 200));
312
313// ── 2. A COMMAND THAT OUTLIVES ITSELF ───────────────────────────────
314//
315// `sleep` is put in the background and the shell exits, so the RUN ends and its
316// process group does not. Output is redirected, or the survivor holds the write
317// end of the pipe open and the run cannot be reported as ended at all.
318const RUN_ID = 'run-1-bash';
319const spec = {
320 t: 'exec', id: RUN_ID,
321 argv: ['bash', '-c', 'sleep 300 </dev/null >/dev/null 2>&1 & echo LEFT=$!'],
322 cwd: GRANT, env: [], stdin: null, timeout_ms: 30000, capture: 'both',
323 fence: { rw: [GRANT], ro: [], deny: [], net: false }, toolkits: [],
324};
325const ran = JSON.parse(await page.evaluate((s) => window.__run(s), spec));
326const leftPid = Number((/LEFT=(\d+)/.exec(ran.stdout || '') || [])[1] || 0);
327check('a command that backgrounds a process runs and ends',
328 ran.exit === 0 && leftPid > 0, JSON.stringify(ran).slice(0, 300));
329check('and the daimon is TOLD the run left something standing',
330 /standing|still running|left/i.test(String(ran.note || '')),
331 JSON.stringify(ran.note || '(no note)').slice(0, 300));
332// The kernel, not the listing: at this point the run is over and the process it
333// left is alive, which is the whole shape of the leak.
334check('and the process it left is alive on the machine', alive(leftPid), `pid ${leftPid}`);
335
336// The hand looks at a group after the command ends, so the listing is asked for
337// a moment later rather than in the same breath.
338await sleep(600);
339const standing = await page.evaluate(() => window.__runs());
340const rows = (() => { try { return JSON.parse(standing).runs || []; } catch (e) { return []; } })();
341const row = rows.find((r) => r && r.id === RUN_ID);
342check('a background process is listed as standing, under the run\'s own identifier',
343 !!row && row.state === 'standing', String(standing).slice(0, 400));
344check('and the listing names the command line it came from',
345 !!row && /sleep 300/.test(String(row.what || '')), row ? String(row.what) : '(no row)');
346
347// ── 3. AND IT CAN BE STOPPED, BY THAT IDENTIFIER ────────────────────
348const sent = await page.evaluate((id) => window.__signal(id, 'term'), RUN_ID);
349check('the signal is accepted by the identifier the run was given', sent === 'sent', String(sent));
350await sleep(800);
351const after = await page.evaluate(() => window.__runs());
352const left = (() => { try { return (JSON.parse(after).runs || []); } catch (e) { return [{ id: 'unreadable' }]; } })();
353check('the run is gone from the listing after it is stopped',
354 !left.some((r) => r && r.id === RUN_ID), String(after).slice(0, 400));
355// And the oracle again, which is the half that cannot be faked by bookkeeping.
356check('AND THE PROCESS IS GONE FROM THE MACHINE', !alive(leftPid), `pid ${leftPid}`);
357
358// ── 4. AND ONLY WHAT THIS HAND STARTED CAN BE NAMED ─────────────────
359//
360// Not a check on the argument: the fence is that the argument cannot express
361// anything else. A pid is not an identifier this hand ever issued, so naming one
362// reaches nothing — and a `sleep` started by THIS PROCESS, which the hand never
363// launched, is untouched by it.
364const mine = spawnSync('bash', ['-c', 'setsid sleep 120 >/dev/null 2>&1 & echo $!'], { encoding: 'utf8' });
365const minePid = Number((mine.stdout || '').trim());
366await sleep(200);
367if (alive(minePid)) {
368 await page.evaluate((p) => window.__signal(String(p), 'kill'), minePid);
369 await sleep(600);
370 check('a pid is not a name this hand answers to, so a process it did not start survives',
371 alive(minePid), `pid ${minePid}`);
372 spawnSync('kill', ['-9', String(minePid)]);
373} else {
374 check('a pid is not a name this hand answers to', false, 'the fixture process never started');
375}
376
377// ── The verdict ─────────────────────────────────────────────────────
378await b.close();
379server.close();
380if (!KEEP) fs.rmSync(BASE, { recursive: true, force: true });
381
382console.log(`\n${ok.length} ok, ${bad.length} failed`);
383if (BREAK) {
384 const must = BREAKS[BREAK].must;
385 const missed = must.filter((m) => !bad.some((n) => n.startsWith(m)));
386 if (missed.length) {
387 console.log(`THE BREAK PROVED NOTHING about: ${missed.join('; ')}`);
388 process.exit(1);
389 }
390 console.log(`break '${BREAK}' reddened every check it names, and ${bad.length} in all`);
391 process.exit(0);
392}
393process.exit(bad.length ? 1 : 0);