Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_handreal.mjs

48.6 KiB, 1 run

created by r2519314175:469, 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_handreal.mjs — the whole chain, with nothing mocked.
2//
3// Every other test of the machine hand stops one link short of the join:
4//
5// `hand/src/*` unit tests drive the runner in-process, no browser at all.
6// `dev/verify_hand.mjs` real Chrome + real extension + the MOCK host,
7// which speaks the protocol and runs nothing.
8// `dev/verify_handrun.mjs` real Chrome + real extension + real app + the
9// MOCK host: it proves the pipeline carries output
10// and status faithfully, and says so itself.
11//
12// So the one thing nobody had watched happen was a daimon asking for a command
13// and a process actually starting on this computer. That is this file:
14//
15// real Chrome + the real extension + the real `daimond-hand` binary +
16// a real command + the real Landlock fence.
17//
18// The assertions are made against what the MODEL was sent — the mock provider's
19// log — and against the KERNEL's own answers, not against the screen. "Something
20// appeared in the panel" is not the question; "did a process run, and did its
21// true output and true exit code reach the daimon" is.
22//
23// ── The two things a reader should be suspicious of ─────────────────
24//
25// A test that runs `echo hello` and finds "hello" has proved nothing a mock
26// could not have faked. So:
27//
28// * Every command that must prove REAL execution reads a nonce this run
29// generated a moment earlier and wrote to disk. A stand-in cannot invent it.
30// * The fence is proved by what the KERNEL refuses: a second nonce is written
31// OUTSIDE the granted folder, and the test asserts both that the command was
32// denied and that the nonce never reached the model.
33//
34// ── A CHAT HAS A WORKSPACE, and a command runs where the user marked ─
35//
36// Rewritten on 2026-08-13. From 5389864 a chat's commands run only in the folders
37// the user marked into that chat's workspace, and this file drove a chat that had
38// marked in nothing — so `Tool::Run` refused every command on the `default_cwd`
39// path, in its own words, BEFORE the fence was ever consulted, and sixteen checks
40// went red against the world as it used to be rather than against a defect. The
41// refusal was right; the fixture was out of date.
42//
43// So the granted folder now holds a folder INSIDE it, `marked/`, and the chat is
44// given that one. Three things follow, and each is asserted below:
45//
46// * WITH NOTHING MARKED IN, a command is refused and the sentence says what to
47// do about it. (Half one, and worth nothing on its own — a refusal is also
48// what a wholly broken chain produces.)
49// * WITH THE FOLDER MARKED IN, a real process starts in it and its real output
50// and real exit code reach the daimon. (Half two, which is what makes half
51// one mean something.)
52// * AND THE MARK IS THE WHOLE OF THE REACH: a file sitting in the granted root
53// but OUTSIDE the marked folder is refused BY THE KERNEL, and its nonce never
54// reaches the model. The grant is not the fence; the mark is.
55//
56// The folder is marked in through the `+` in the chat footer's workspace group —
57// the app's own control, driven as a person drives it. It has to exist in the
58// PAGE's workspace as well as on the machine, because that picker lists what
59// `Files.entries` lists, and in a harness the page's workspace is OPFS. So the
60// folder is laid down in both, and the two halves are the same folder by name.
61// A fixture that instead wrote the holding onto the chat record would be proving
62// the fence against a world only this file ever built, which is the mistake
63// `dev/verify_scope.mjs` made and had to be rewritten out of.
64//
65// ── What a user must do, and what this test therefore does ──────────
66//
67// 1. `cargo build --release --manifest-path hand/Cargo.toml` (NOT `-p`: the
68// hand is its own workspace).
69// 2. `hand/install/install.sh` — run here with `--dir`, pointed at this test's
70// throwaway profile, so the user's real browser profile is never touched.
71// The real installer, not a copy of its output.
72// 3. **Name the granted folder.** The hand refuses to serve without one, and
73// Chrome hands a native messaging host its OWN environment — so a variable
74// set in some terminal is not there when the browser launches the host. The
75// root therefore comes from `root.txt` beside the journal, which is the only
76// mechanism that works for a browser started from a desktop launcher. This
77// test asserts `DAIMOND_HAND_ROOT` is UNSET, so the root it reads back can
78// only have come from the file.
79// 4. Allow the hand in the window that opens on the first command.
80// 5. **Open the folder the hand was granted, in Daimond.** `hand/REVIEW.md`
81// §1.14 refuses a command where the two ends cannot be shown to mean one
82// folder, and no automated browser can satisfy that — a page holds a real
83// folder only through a native dialog no harness can answer. The refusal is
84// asserted here against the real hand and then stood in for; the note beside
85// the stand-in says exactly what is substituted and what is not.
86// 6. **Mark a folder into the chat's workspace, with the `+` in the Workspace
87// group** -- NOT the paperclip, which attaches for reading. The grant
88// says what Daimond MAY reach on this computer; the mark says where THIS
89// conversation works. Both are the user's own press, and neither stands in
90// for the other — which is why step 5 being stood in for leaves step 6 to be
91// done for real, through the control that does it.
92//
93// ── Proving it can fail ─────────────────────────────────────────────
94//
95// `--break <name>` serves a damaged `www/js/daimond.js` to the real page through
96// `page.route`; the run is then expected to FAIL, and a break whose anchor does
97// not match aborts rather than passing quietly. Both damage THE SCOPE THE PAGE
98// ASKS FOR and never the engine, the hand or the kernel, which are the things
99// under test.
100//
101// node dev/verify_handreal.mjs --break nomark # the mark never reaches the engine
102// node dev/verify_handreal.mjs --break inventscope # the page invents a workspace
103//
104// `DAIMOND_HAND_JOURNAL_DIR` is set here, and only for test isolation: without
105// it the journal — and `root.txt` with it — would be written into the user's own
106// `~/.local/share/daimond/hand/journal`, which is their configuration and not
107// this test's to edit. A real user does not set it.
108//
109// ── Running it ──────────────────────────────────────────────────────
110//
111// xvfb-run -a -s "-screen 0 1400x900x24" node dev/verify_handreal.mjs
112//
113// --no-cargo skip the `cargo test` case (it costs about ten seconds)
114// --keep leave the scratch tree behind for inspection
115// --wasm rebuild the wasm bundle first
116// --break <n> serve a damaged page and expect failures (see above)
117//
118// Needs nothing running: the dev server and the mock provider are started here
119// if they are not already up. Headed, because Chromium loads an unpacked
120// extension in no other mode.
121import fs from 'node:fs';
122import net from 'node:net';
123import path from 'node:path';
124import { spawn, spawnSync } from 'node:child_process';
125import { fileURLToPath } from 'node:url';
126
127import { open as openApp, newChat, chat, transcript, mockLog, clearMockLog, scratch } from './harness.mjs';
128import { whyStaleBinary, whyStaleWasm, refuse } from './staleguard.mjs';
129
130const HERE = path.dirname(fileURLToPath(import.meta.url));
131const ROOT = path.join(HERE, '..');
132const WWW = path.join(ROOT, 'www');
133const SRC = path.join(ROOT, 'ext'); // harness swaps in the dev build
134const EXTID = 'mpliijponglmmffjnonahhignkpkhmij';
135const INSTALL = path.join(ROOT, 'hand/install/install.sh');
136const HAND = path.join(ROOT, 'hand/target/release/daimond-hand');
137
138const argv = process.argv.slice(2);
139const NO_CARGO = argv.includes('--no-cargo');
140const KEEP = argv.includes('--keep');
141const WASM = argv.includes('--wasm');
142
143// One tree, so cleanup is one `rm -rf` and nothing of this test survives it.
144//
145// base/profile the browser's user-data-dir, and therefore the ONLY place a
146// host manifest is written. The user's own browser is untouched.
147// base/journal the hand's journal, and `root.txt` beside it.
148// base/work THE GRANTED ROOT. What Daimond may reach on this computer at
149// all — which is NOT the same as what any one chat may run in.
150// base/work/marked
151// THE MARKED FOLDER: the one the user puts into this chat's
152// workspace, and therefore the only place a command runs. It is
153// mirrored in the page's own workspace so the app's own picker
154// can offer it (see the header).
155// base/outside deliberately NOT granted: the fence's job is to make this
156// unreachable, and the secret in it is how that is proved.
157const BASE = scratch('handreal');
158const PROFILE = path.join(BASE, 'profile');
159const JOURNAL = path.join(BASE, 'journal');
160const GRANT = path.join(BASE, 'work');
161const MARKED = 'marked';
162const MARKED_ABS = path.join(GRANT, MARKED);
163const OUTSIDE = path.join(BASE, 'outside');
164const HOSTS = path.join(PROFILE, 'NativeMessagingHosts');
165
166const BREAK = (() => {
167 const i = process.argv.indexOf('--break');
168 return i > 0 ? String(process.argv[i + 1] || '') : '';
169})();
170
171// Both damage the page's answer to "what did the user mark into this chat?", and
172// neither touches the engine, the hand or the kernel.
173const BREAKS = {
174 // The mark is made, the footer draws it, and the engine is handed nothing —
175 // which is what the app did for every chat before the mark existed, and what a
176 // caller does who forgets that `ws` is the field the fence is built from. This
177 // is the exact state this file was red in from 5389864 until 2026-08-13.
178 nomark: {
179 file: 'js/daimond.js',
180 find: ` .filter(function (a) { return !!a.ws; })`,
181 with: ` .filter(function (a) { return false && !!a.ws; })`,
182 },
183 // The other direction, and the dangerous one: the page hands over a folder
184 // nobody marked in, so a chat whose workspace is empty runs commands anyway.
185 inventscope: {
186 file: 'js/daimond.js',
187 find: ` if (trashed(chatId)) return [];`,
188 with: ` if (trashed(chatId)) return [];\n\t\treturn ['${MARKED}'];`,
189 },
190};
191
192const ok = [], bad = [];
193const check = (name, pass, detail) => {
194 (pass ? ok : bad).push(name);
195 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
196};
197const note = (s) => console.log(' · ' + s);
198
199const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
200
201/// A value no stand-in could have invented, so finding it in what the model was
202/// sent proves a real process read a real file.
203const nonce = (tag) => `${tag}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
204
205/// Serve one deliberately damaged file in place of the real one, before the app
206/// is ever loaded. An anchor that does not match exactly once aborts the run: a
207/// break that broke nothing would leave a green summary meaning the opposite of
208/// what it says.
209async function installBreak(page) {
210 if (!BREAK) return;
211 const spec = BREAKS[BREAK];
212 if (!spec) {
213 console.error(`--break ${BREAK}: no such break. One of: ${Object.keys(BREAKS).join(', ')}`);
214 process.exit(2);
215 }
216 const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8');
217 const n = src.split(spec.find).length - 1;
218 if (n !== 1) {
219 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
220 + 'so nothing was broken and the run below would prove nothing.');
221 process.exit(2);
222 }
223 const body = src.replace(spec.find, spec.with);
224 await page.route('**/' + spec.file,
225 (r) => r.fulfill({ status: 200, contentType: 'application/javascript', body }));
226}
227
228// ── The servers ─────────────────────────────────────────────────────
229//
230// Both, or nothing works and the reason is invisible: without the mock provider
231// every model turn fails and the transcript says only that Daimond could not
232// answer, which reads as a broken app rather than a missing server.
233
234function listening(port) {
235 return new Promise((resolve) => {
236 const s = net.connect(port, '127.0.0.1');
237 s.once('connect', () => { s.destroy(); resolve(true); });
238 s.once('error', () => resolve(false));
239 });
240}
241
242const started = [];
243async function serve(name, args, port) {
244 if (await listening(port)) { note(`${name} already up on ${port}`); return; }
245 const p = spawn('node', args, { cwd: ROOT, stdio: 'ignore' });
246 started.push(p);
247 for (let i = 0; i < 100; i++) {
248 if (await listening(port)) { note(`started ${name} on ${port}`); return; }
249 await sleep(100);
250 }
251 throw new Error(`${name} did not come up on ${port}`);
252}
253
254// ── What the model was shown ────────────────────────────────────────
255
256/// The last tool result the model was sent, which is the whole point: a run that
257/// draws output on the screen and hands the model nothing has achieved nothing.
258function toolResult() {
259 const reqs = mockLog();
260 for (let i = reqs.length - 1; i >= 0; i--) {
261 const msgs = reqs[i].messages || [];
262 for (let j = msgs.length - 1; j >= 0; j--) {
263 if (msgs[j].role === 'tool') return String(msgs[j].content || '');
264 }
265 }
266 return '';
267}
268
269/// Run one command as a daimon would, and return what the model was handed.
270///
271/// # Arguments
272/// * `s` - The session.
273/// * `spec` - The `run` arguments, as the model would compose them.
274/// * `timeout` - How long to wait for the turn.
275async function run(s, spec, timeout = 120000) {
276 clearMockLog();
277 await chat(s, '@tool run ' + JSON.stringify(spec), { timeout });
278 return toolResult();
279}
280
281// ── Build, install, configure ───────────────────────────────────────
282
283fs.rmSync(BASE, { recursive: true, force: true });
284for (const d of [PROFILE, JOURNAL, GRANT, MARKED_ABS, OUTSIDE]) fs.mkdirSync(d, { recursive: true });
285// 0700, and it is load-bearing. `root.txt` has to sit in the journal directory,
286// which makes `journal::is_ours` answer no — the directory now holds something
287// that is not the journal's own furniture — so the hand will NOT tighten it, and
288// a directory anyone else can read is a refusal to start. The hand creates its
289// own directory at 0700 on first run, so a user who lets it do that is fine; one
290// who runs `mkdir -p` with the usual umask gets 0755 and a startup failure whose
291// message names only the variable. Recorded here because the installer's README
292// now has to say it.
293fs.chmodSync(JOURNAL, 0o700);
294
295// The release binary, into `hand/target` — the path `install.sh` looks in by
296// default and the one `hand/install/README.md` names, so what is verified is
297// what a reader of that file will have. Three other agents share this tree, so a
298// build that fails because somebody is mid-edit is retried rather than fatal.
299//
300// `CARGO_TARGET_DIR` is REMOVED from the build's environment, and that is not
301// tidying: an agent working in this tree usually has one set, cargo would write
302// the new binary there, and `HAND` — the path `install.sh` registers and this
303// test therefore runs — would still be whatever was built last. See the same
304// note in `verify_kitfence.mjs`, where an inherited one made a security test
305// pass against a binary from before the fix.
306const buildEnv = { ...process.env };
307delete buildEnv.CARGO_TARGET_DIR;
308//
309// `DAIMOND_NO_BUILD` skips the build and NOTHING else: the staleness guard below
310// runs either way, so it cannot make this file report success against code it
311// did not test — only refuse. It exists because cargo relinks an output it finds
312// backdated, so with the build in the way the guard can never be watched
313// refusing. Same hatch as `PTYEDGE_NO_BUILD` in verify_ptyedge.mjs.
314let built = process.env.DAIMOND_NO_BUILD ? true : null;
315for (let i = 1; i <= 3 && !built; i++) {
316 const r = spawnSync('cargo', ['build', '--release', '--manifest-path', 'hand/Cargo.toml'],
317 { cwd: ROOT, encoding: 'utf8', env: buildEnv });
318 if (r.status === 0) { built = true; break; }
319 console.log((r.stderr || '').split('\n').filter((l) => /^error/.test(l)).slice(0, 5).join('\n'));
320 if (i < 3) { note(`the hand did not build (attempt ${i}); waiting 30 s in case somebody is mid-edit`); await sleep(30000); }
321}
322check('the hand builds from source', !!built && fs.existsSync(HAND),
323 built ? HAND : 'cargo build --release --manifest-path hand/Cargo.toml failed three times');
324if (!built) { console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); process.exit(1); }
325
326// A build that exits 0 is not the same claim as an artefact built from this
327// tree. Cargo's own dep-info file is the oracle — it names every source that
328// went into the link, this crate's and every fe2o3 crate's — so nothing is
329// hardcoded and an upstream change counts as staleness.
330refuse(whyStaleBinary(HAND, {
331 subject: 'The hand, and therefore every fence below it,',
332 what: 'hand',
333 rebuild: 'cargo build --release --manifest-path hand/Cargo.toml',
334}));
335
336// ── And the app's half of it ────────────────────────────────────────
337//
338// The wasm the browser loads composes every request the hand is sent, so it is
339// as much the code under test as the binary is. This used to WARN and carry on
340// to a green summary, against a list of three hand-picked sources — which is two
341// failures in one: `src/prompts.rs`, `src/skills.rs`, `src/wasm/opfs.rs` and
342// `src/wasm/diamond.rs` all changed on 2026-08-03 and none of them was on the
343// list, and a warning inside a run that then reports success is not a guard.
344// Now it refuses, against every `.rs` there is.
345const wasmFile = path.join(ROOT, 'www/pkg/oxedyne_daimond_bg.wasm');
346if (WASM) {
347 note('rebuilding the wasm bundle');
348 spawnSync('bash', ['dev/build-wasm.sh'], { cwd: ROOT, stdio: 'inherit' });
349}
350refuse(whyStaleWasm(wasmFile, path.join(ROOT, 'src'), {
351 subject: 'What the app asks the hand for',
352 holds: 'every tool call this file makes',
353}));
354
355// The nonces. THREE of them, because there are three places and only two used to
356// be told apart:
357//
358// INSIDE in the marked folder, which a real command must be able to read.
359// UNMARKED in the granted root but NOT in the marked folder. The grant reaches
360// it and this chat does not, so the kernel must refuse it exactly as
361// it refuses the one outside the grant altogether. Without this file
362// the run cannot tell "fenced to the mark" from "fenced to the grant",
363// which since 5389864 is the difference the whole design turns on.
364// OUTSIDE outside the grant entirely.
365const INSIDE_NONCE = nonce('inside');
366const UNMARKED_NONCE = nonce('unmarked');
367const OUTSIDE_NONCE = nonce('secret');
368fs.writeFileSync(path.join(MARKED_ABS, 'inside.txt'), INSIDE_NONCE + '\n');
369fs.writeFileSync(path.join(GRANT, 'unmarked.txt'), UNMARKED_NONCE + '\n');
370fs.writeFileSync(path.join(OUTSIDE, 'secret.txt'), OUTSIDE_NONCE + '\n');
371
372// The granted root, named the way a browser-launched hand will actually read it:
373// a line in `root.txt` beside the journal. The comment is not decoration — it is
374// the first line of the file, and the hand is expected to skip it.
375fs.writeFileSync(path.join(JOURNAL, 'root.txt'),
376 `# The one folder Daimond's machine hand may work in.\n${GRANT}\n`);
377
378// Register the REAL binary, with the REAL installer, into the test profile's own
379// NativeMessagingHosts directory. `--dir` is the documented way to do exactly
380// this, so running it here verifies the instruction as well as the outcome.
381const inst = spawnSync('bash', [INSTALL, '--dir', HOSTS, HAND], { cwd: ROOT, encoding: 'utf8' });
382const manifestPath = path.join(HOSTS, 'com.oxedyne.daimond.hand.json');
383check('install.sh registers the real binary in the profile it was pointed at',
384 inst.status === 0 && fs.existsSync(manifestPath),
385 (inst.stderr || inst.stdout || '').trim().split('\n').slice(-2).join(' '));
386let manifest = {};
387try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (e) { /* checked below */ }
388check('the manifest names the built hand and this extension',
389 manifest.path === HAND && (manifest.allowed_origins || []).includes(`chrome-extension://${EXTID}/`),
390 JSON.stringify(manifest.path) + ' ' + JSON.stringify(manifest.allowed_origins));
391
392// Chrome hands the host its own environment, which is this process's. The
393// journal is redirected so the user's own is not written to; the ROOT variable is
394// removed so that the root the hand reports can only have come from `root.txt`.
395process.env.DAIMOND_HAND_JOURNAL_DIR = JOURNAL;
396delete process.env.DAIMOND_HAND_ROOT;
397
398// What the children will bind: `serve.mjs` reads DAIMOND_PORT and `mockllm.mjs`
399// DAIMOND_MOCK_PORT, so the wait below is asking about the port they chose.
400const APP_PORT = Number(process.env.DAIMOND_PORT || 8777);
401const MOCK_PORT = Number(process.env.DAIMOND_MOCK_PORT || 9099);
402await serve('dev server', ['dev/serve.mjs'], APP_PORT);
403await serve('mock provider', ['dev/mockllm.mjs'], MOCK_PORT);
404
405// ── The toolchain, inside the granted folder ────────────────────────
406//
407// `cargo test` needs cargo, rustc and the sysroot, and the fence cannot reach
408// them where they live. `fence_spec` in `src/tools.rs` builds every allowed path
409// by joining a workspace-RELATIVE name onto the granted root, so no rule it can
410// produce ever names `~/.cargo` or `~/.rustup`. The toolchain therefore has to be
411// inside the granted folder, and it is put there with `cp -al` — a hard-link
412// farm, which costs directory entries and no data.
413//
414// That is a real finding and not a convenience: as things stand, a user who
415// wants a daimon to run `cargo test` must keep a toolchain inside the folder
416// they granted, or `src/tools.rs` must learn to carve the toolchain in read-only.
417//
418// And since 5389864 it is a stronger finding than it was: the toolchain must be
419// inside the MARKED folder, not merely inside the grant, because the fence is
420// built from the mark. `<grant>/toolchain` beside a marked `<grant>/project` is
421// refused by the kernel like anything else the chat was not given.
422//
423// Nothing else is smuggled in. The environment is EMPTY — `src/tools.rs` sends
424// `"env":[]` and `exec.rs` clears what is left — so cargo is told where rustc is
425// through `.cargo/config.toml`, which cargo reads from the working directory
426// upward. Nothing is said about `TMPDIR`: the hand gives every run a private
427// scratch directory inside its fence and points `TMPDIR`, `TMP` and `TEMP` at
428// it, which is what lets a build that writes temporary files finish at all. That
429// is asserted below rather than assumed, because it is the difference between a
430// linker that works and one that fails half way through.
431function plantCargoProject() {
432 const sysroot = spawnSync('rustc', ['--print', 'sysroot'], { encoding: 'utf8' });
433 if (sysroot.status !== 0) return null;
434 const tc = sysroot.stdout.trim();
435 const farm = path.join(MARKED_ABS, 'toolchain');
436 const cp = spawnSync('cp', ['-al', tc, farm], { encoding: 'utf8' });
437 if (cp.status !== 0) return null;
438 const proj = path.join(MARKED_ABS, 'proj');
439 fs.mkdirSync(path.join(proj, 'src'), { recursive: true });
440 fs.mkdirSync(path.join(proj, '.cargo'), { recursive: true });
441 fs.writeFileSync(path.join(proj, 'Cargo.toml'),
442 '[package]\nname = "handreal"\nversion = "0.1.0"\nedition = "2021"\n');
443 // The test asserts on a nonce, so a pass cannot come from a cached artefact
444 // or from anybody's imagination: this source did not exist a second ago.
445 fs.writeFileSync(path.join(proj, 'src/lib.rs'),
446 `pub fn tag() -> &'static str { "${INSIDE_NONCE}" }\n`
447 + '#[cfg(test)]\nmod t {\n'
448 + `\t#[test] fn the_nonce_survives_a_real_compile() { assert_eq!(super::tag(), "${INSIDE_NONCE}"); }\n`
449 + '}\n');
450 fs.writeFileSync(path.join(proj, '.cargo/config.toml'),
451 `[build]\nrustc = "${farm}/bin/rustc"\nrustdoc = "${farm}/bin/rustdoc"\n\n`
452 + `[target.${process.arch === 'x64' ? 'x86_64' : process.arch}-unknown-linux-gnu]\n`
453 + 'linker = "/usr/bin/cc"\n');
454 return { farm, proj };
455}
456
457// ── The browser ─────────────────────────────────────────────────────
458
459const s = await openApp({
460 headed: true, name: 'handreal', extension: SRC, profile: PROFILE, route: installBreak,
461});
462const b = s.browser;
463const page = s.page;
464
465/// Find the grant window and click Allow, in the background, while the turn that
466/// provoked it is still running. It is the extension's own page, so the click is
467/// a real one — and there is no second Chrome prompt behind it: that window IS
468/// the approval.
469async function allowHand(ms = 30000) {
470 const until = Date.now() + ms;
471 while (Date.now() < until) {
472 for (const p of b.pages()) {
473 if (/grant\.html/.test(p.url())) {
474 await p.waitForLoadState('domcontentloaded').catch(() => {});
475 await sleep(300);
476 const head = await p.evaluate(() =>
477 (document.getElementById('head') || {}).textContent || '').catch(() => '');
478 await p.click('#allow').catch(() => {});
479 return head;
480 }
481 }
482 await sleep(150);
483 }
484 return null;
485}
486
487// ── The turn's network question ─────────────────────────────────────
488//
489// A chat that has read a command's output is TAINTED from that moment, so the
490// engine asks before the NEXT command may reach the network (`hand/REVIEW.md`
491// §1.13, `Tool::run`'s `NetStep::Ask`) and holds the turn on a modal until
492// somebody answers. Nothing in this file answered it, so every command after
493// the first stopped on that dialog until `chat`'s own timeout expired, `chat`
494// returned with the turn still running, and `run` below returned THE PREVIOUS
495// COMMAND'S tool result. That is the whole of what "a real non-zero exit
496// reaches the model as itself" and "a command that fails hands the model its
497// real stderr" were reporting on 2026-08-17: `/bin/false`'s check was reading
498// `/bin/cat inside.txt`'s result, and the `cat` of a missing file was reading
499// `/bin/false`'s.
500//
501// The answer is NO, which is the fence every command below has always been
502// measured against; a yes would silently change what each of them ran with.
503// READ FROM THE APP, not copied out of it. This was the literal string, and on
504// 2026-08-19 `permmode.net_title` changed -- it said "this turn" and meant this
505// chat -- which would have left the watcher below never recognising the dialog
506// and therefore never answering it. That is not a red: it is the failure of
507// 2026-08-18, where an unanswered network dialog made `chat()` time out with the
508// turn still running and every assertion after it read ONE COMMAND LATE. A
509// verifier that silently measures the wrong command is worse than one that stops.
510//
511// `t()` falls back to the key's own name if the key is gone, which no dialog will
512// ever match, so a DELETED key stops this loudly instead of quietly.
513const netTitle = async (page) => await page.evaluate(() =>
514 (window.DaimondI18n ? DaimondI18n.t('permmode.net_title') : 'permmode.net_title'));
515let netAsked = 0;
516let netStop = false;
517let netWatch = null;
518
519/// Say no to the network question for as long as this run lasts, and count how
520/// often it was put.
521async function answerNet(page) {
522 while (!netStop) {
523 const asked = await page.evaluate((title) => {
524 for (const card of document.querySelectorAll('.dlg-card')) {
525 const h = card.querySelector('h2');
526 if (h && h.textContent.indexOf(title) >= 0) return true;
527 }
528 return false;
529 }, await netTitle(page)).catch(() => false);
530 // PRESSED, rather than resolved from inside the page: the button is what
531 // a user has, and a question answered by reaching past it proves nothing
532 // about the one they are actually shown.
533 if (asked) {
534 const said = await page.click('.dlg-card .dlg-cancel', { timeout: 2000 })
535 .then(() => true, () => false);
536 if (said) netAsked++;
537 }
538 await sleep(200);
539 }
540}
541
542let handPid = '';
543try {
544 await sleep(500);
545
546 check('the extension announced itself to the app',
547 await page.evaluate(() => !!document.documentElement.dataset.daimondHands));
548 check('the page relay is loaded and wired',
549 await page.evaluate(() => !!(window.DaimondHand && window.DaimondHand.run)));
550
551 // A real compile is slower than a mock's scripted chatter, and the page's
552 // own waits are what decide whether a quiet command is a dead one.
553 await page.evaluate(() => window.DaimondHand._setWaitsForTest({
554 grace: 60000, slack: 180000, hello: 30000,
555 }));
556 netWatch = answerNet(page);
557
558 // The chat every turn below is sent to, and therefore the chat whose workspace
559 // decides where its commands may run. Opened before anything is asked of it,
560 // because the folder is marked into THIS chat and a second one would have an
561 // empty workspace of its own.
562 await newChat(s);
563 await sleep(400);
564 const focus = await page.evaluate(() => window.DaimondAttach.focus());
565 const chatId = focus && focus.id;
566 check('a chat is in focus, so there is a workspace to mark a folder into',
567 !!chatId && focus.kind === 'chat', JSON.stringify(focus));
568
569 // The other half of the marked folder. `MARKED_ABS` is the directory on the
570 // machine that the commands below actually run in; this is the same folder in
571 // the workspace the PAGE holds, which in a harness is OPFS — no browser can be
572 // made to answer `showDirectoryPicker()`, which is the same limitation the
573 // pairing stand-in below exists for. It is laid down through the tool door,
574 // which is how a turn would have made it, and it is what puts the folder in
575 // front of the picker: `Files.entries` is the panel's own listing and lists
576 // nothing that is not there.
577 await page.evaluate(async (dir) => {
578 const m = await import('/pkg/oxedyne_daimond.js');
579 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
580 await app.run_tool('dir_create', JSON.stringify({ path: dir }));
581 }, MARKED);
582
583 // ── The hand answers, and says what it can enforce ──────────────
584 //
585 // The FIRST command is not a command that runs. `hand/REVIEW.md` §1.14 is
586 // armed: the hand was granted a folder on this machine, this page's workspace
587 // is the browser's own sandbox, and the two cannot be shown to be the same
588 // folder — so the daimon is handed a refusal instead of output. Asserted here,
589 // on the real chain, because this is the only place in the repository where
590 // that refusal meets a real hand.
591 const grant = allowHand();
592 const refused = await run(s, { argv: ['/bin/cat', 'inside.txt'], timeout_ms: 30000 });
593 const head = await grant;
594 check('running a command asks the user first, in the extension\'s own window',
595 !!head && /computer/i.test(head), String(head));
596 check('a command is REFUSED while the page cannot show it holds the hand\'s folder',
597 /^Refused:/.test(refused)
598 && /lives in the browser and not in a folder on this machine/.test(refused),
599 refused.slice(0, 240));
600 check('and nothing ran: the model was given a sentence, not a result',
601 !refused.includes(INSIDE_NONCE) && !/exit code/.test(refused), refused.slice(0, 200));
602
603 const st = JSON.parse(await page.evaluate(() => window.DaimondHand.status()));
604 check('a real hand answered, on the machine transport',
605 st.transport === 'machine' && !!st.version, JSON.stringify(st).slice(0, 200));
606 check('and the relay refuses the pairing over the folder, in so many words',
607 st.paired === false && st.workspace === 'mismatch' && st.reason === st.workspace_reason,
608 JSON.stringify(st).slice(0, 240));
609 check('the granted root came from root.txt, with DAIMOND_HAND_ROOT unset',
610 st.root === fs.realpathSync(GRANT), `${st.root} vs ${GRANT}`);
611 check('the hand reports a kernel fence, not a claim of one',
612 (st.caps || []).includes('fence:linux') && (st.caps || []).some((c) => /^landlock:abi-\d+$/.test(c)),
613 (st.caps || []).join(' '));
614 const wsCap = (st.caps || []).find((c) => c.indexOf('ws:') === 0) || '';
615 check('and it published an identity for the folder it was granted, which is on disk',
616 /^ws:[0-9a-f]{32}$/.test(wsCap) && fs.existsSync(path.join(GRANT, '.daimond/workspace.id'))
617 && fs.readFileSync(path.join(GRANT, '.daimond/workspace.id'), 'utf8').includes(wsCap.slice(3)),
618 wsCap);
619 note(`hand ${st.version} on ${st.os}: ${(st.caps || []).join(' ')}`);
620
621 // ── Standing in for the folder verdict, and only for that ───────
622 //
623 // Every check below runs a command, and every one of them meets the refusal
624 // just asserted. It cannot be arranged away: a page holds a real folder only
625 // through `showDirectoryPicker()`, a native dialog no automated browser can
626 // answer, so a headless run necessarily has an OPFS workspace and is
627 // necessarily refused. There is no configuration in which this check passes by
628 // accident, which is what makes standing in for it honest rather than a
629 // weakening — `dev/verify_scope.mjs` stands in for the whole of `status` for
630 // the same reason, and `dev/verify_wsident.mjs` tests the refusal itself,
631 // against two real directory handles.
632 //
633 // Only the folder VERDICT is stood in for. What the hand said about itself —
634 // its root, its caps, its os — passes through untouched, because the fence
635 // every command below runs under is composed from it.
636 await page.evaluate(() => {
637 var real = window.DaimondHand.status;
638 window.__realStatus = function () { return real.call(window.DaimondHand); };
639 window.DaimondHand.status = function () {
640 return real.call(window.DaimondHand).then(function (raw) {
641 var out = JSON.parse(raw);
642 if (out.workspace && out.workspace !== 'ok') {
643 out.paired = true;
644 delete out.reason;
645 out.workspace = 'stood in for by verify_handreal.mjs';
646 }
647 return JSON.stringify(out);
648 });
649 };
650 });
651 const stood = JSON.parse(await page.evaluate(() => window.DaimondHand.status()));
652 check('the stand-in changes the folder verdict and nothing else',
653 stood.paired === true && stood.root === st.root && stood.os === st.os
654 && JSON.stringify(stood.caps) === JSON.stringify(st.caps),
655 JSON.stringify(stood).slice(0, 200));
656
657 // ── Half one: nothing marked in, and the model is told why ──────
658 //
659 // The hand is paired now, the fence is expressible, and there is still nowhere
660 // for a command to go: the grant says what Daimond may reach on this computer,
661 // and this chat has been given none of it. `Tool::Run` answers on the
662 // `default_cwd` path, above the fence and above the hand — so nothing below is
663 // reached and no process starts.
664 const bare = await run(s, { argv: ['/bin/cat', 'inside.txt'], timeout_ms: 30000 });
665 check('WITH NOTHING MARKED IN, a command is refused rather than run',
666 /^Refused: /.test(bare) && /holds nothing on this computer/.test(bare), bare.slice(0, 200));
667 // The CONTROL, and the right one. This asserted `/paperclip/` and was green
668 // while the paperclip attaches for READING and grants no writing at all --
669 // so the sentence sent the user to a button that changed nothing, they
670 // pressed it, and the next command was refused in the same words. The `+`
671 // in the Workspace group is what marks a folder in. The negative half is
672 // the one that matters: naming the right control is no use while the wrong
673 // one is still named beside it.
674 check('and the sentence says what to do about it, and where',
675 /\+ in the Workspace group/.test(bare) && !/paperclip/.test(bare)
676 && /mark it into this chat's workspace/.test(bare), bare.slice(0, 400));
677 // The refusal a chat gets and the refusal a Diamond gets are different
678 // sentences on purpose (`ToolContext::is_chat_scoped`), and a model handed the
679 // wrong one is sent to a panel that is not where a chat's workspace is changed.
680 // Asserted as a property OF THE REFUSAL — `!/Diamond/` is also true of a
681 // command's output, so a check that only looked for the absence of the word
682 // would pass in exactly the case where there is no refusal to describe.
683 check('and it is the CHAT\'s words: no Diamond, no Workspace panel',
684 /^Refused: /.test(bare) && !/Diamond/.test(bare), bare.slice(0, 300));
685 check('and nothing ran: the nonce in the folder never reached the model',
686 !bare.includes(INSIDE_NONCE) && !/exit code/.test(bare), bare.slice(0, 200));
687
688 // ── The user marks the folder in, with the control that does it ─
689 //
690 // The `+` in the footer's workspace group: the one control whose whole job is
691 // to put a folder into this chat's workspace, driven through its dialog as a
692 // person drives it. Not `DaimondAttach.chatWs`, which would set the field and
693 // prove only that the field exists — the press is the permission, and a press
694 // that reached nothing is one of the two defects this surface was rebuilt over.
695 await page.click('#chat-attachments .ws-group [data-act="attach-add"]', { force: true });
696 await page.waitForSelector('.attach-pick-row', { timeout: 10000 });
697 const ticked = await page.evaluate((name) => {
698 const row = [...document.querySelectorAll('.attach-pick-row')]
699 .find((x) => ((x.querySelector('.attach-pick-name') || {}).textContent || '').indexOf(name) >= 0);
700 if (!row) return [...document.querySelectorAll('.attach-pick-name')]
701 .map((x) => x.textContent).join(', ') || 'the picker listed nothing';
702 row.querySelector('input').click();
703 return 'ticked';
704 }, MARKED);
705 check('the folder is in the page\'s own workspace, for the picker to offer',
706 ticked === 'ticked', ticked);
707 await page.click('.dlg-ok', { force: true });
708 await sleep(1000);
709 const scope = await page.evaluate((id) => window.DaimondAttach.chatScope(id), chatId);
710 check('MARKING IT IN is what the engine is handed as this chat\'s workspace',
711 Array.isArray(scope) && scope.indexOf(MARKED) >= 0, JSON.stringify(scope));
712
713 // ── Half two: a real process, and its real output ───────────────
714 //
715 // The same command as the refusal above, in the same chat, with one thing
716 // changed: the folder is in the workspace now. That is the whole of the
717 // difference, and it is what makes the refusal above evidence of a live
718 // mechanism rather than of a chain that could not run anything either way.
719 const first = await run(s, { argv: ['/bin/cat', 'inside.txt'], timeout_ms: 30000 });
720 check('a real command\'s stdout reaches the model, nonce and all',
721 first.includes(INSIDE_NONCE), first.slice(0, 240));
722 check('and it is marked as a stranger\'s words, naming the command',
723 /untrusted content begins — run: \/bin\/cat inside\.txt/.test(first), first.slice(0, 160));
724 check('and carries a zero exit', /\[exit code: 0\]/.test(first), first.slice(-120));
725 check('the person watching saw it too',
726 (await transcript(s)).includes(INSIDE_NONCE), '');
727
728 // The process really was fenced: something started, and the fence it started
729 // under is the one the hand planned. Read from the hand's own journal below.
730
731 // ── A real non-zero exit is reported as non-zero ────────────────
732 //
733 // This was a live defect: `extract_json_number` parses a u64, so the -1 that
734 // means "no status" failed to parse and defaulted to ZERO, and a crashed
735 // build was handed to the model as a green one.
736 let r = await run(s, { argv: ['/bin/false'], timeout_ms: 30000 });
737 // A COMMAND FENCED WHERE NOTHING UNTRUSTED LIVES KEEPS THE TURN'S NETWORK, and that is
738 // the owner's decision of 2026-08-24, not a defence being dropped.
739 //
740 // This check asserted the opposite until then, because every `run` tainted the turn
741 // unconditionally: the envelope round a command's output and the loss of the network
742 // were one call, and only the envelope was ever argued for. What that cost was measured
743 // on a real development run -- 26 of 30 tool results carried `[no network: …]`, 18.5% of
744 // the bytes the daimon read, and the turn lost its network to its own first `grep` of
745 // the owner's own source, while `egress_check` fired ZERO times because a daimon doing
746 // source work calls no web tool at all. The permission dialog was never the cost; the
747 // withheld network was.
748 //
749 // So the taint now follows the FENCE: a command whose fence could reach a stranger's
750 // words costs the turn its network, and one fenced to a folder the user marked does not.
751 // The fence here is that marked folder, so no question is right. The other half -- a
752 // fence with the mailbox in it still costing the network -- is held in Rust by
753 // `test_a_command_whose_fence_reaches_the_mailbox_still_takes_the_network_away`
754 // (src/tools.rs) and in the browser by `dev/verify_daimonreach.mjs`.
755 check('a command fenced where nothing untrusted lives does NOT cost the turn its network',
756 netAsked === 0, 'the question was put ' + netAsked + ' time(s)');
757 check('a real non-zero exit reaches the model as itself',
758 /\[exit code: 1\]/.test(r) && !/exit code: 0/.test(r), r.slice(-200));
759
760 r = await run(s, { argv: ['/bin/cat', 'no-such-file-here.txt'], timeout_ms: 30000 });
761 check('a command that fails hands the model its real stderr',
762 /\[stderr\]/.test(r) && /No such file/i.test(r), r.slice(0, 240));
763 check('and a non-zero code with it', /\[exit code: [1-9]/.test(r), r.slice(-120));
764
765 // ── The kernel refuses, and the refusal reaches the model ───────
766 //
767 // Not a path check in the app, and not a string match in the hand: the file
768 // exists, `cat` is a real `cat`, and the only thing between them is Landlock.
769 const denied = await run(s, { argv: ['/bin/cat', path.join(OUTSIDE, 'secret.txt')], timeout_ms: 30000 });
770 check('a file outside the fence is refused BY THE KERNEL',
771 /Permission denied/i.test(denied), denied.slice(0, 300));
772 check('and the secret outside the fence never reached the model',
773 !denied.includes(OUTSIDE_NONCE), denied.slice(0, 200));
774 check('the refusal arrives as a refusal, not as a Daimond error',
775 /untrusted content begins/.test(denied) && /\[exit code: [1-9]/.test(denied)
776 && !/^Refused: the machine hand/.test(denied), denied.slice(0, 300));
777 // Two controls, without which the denial above proves nothing at all. A
778 // denial is only evidence of a fence if the same command succeeds when the
779 // fence is the only thing that changed — so: the same `cat` on the same file
780 // with NOTHING fencing it, and the same `cat` on a file inside the fence.
781 const unfenced = spawnSync('/bin/cat', [path.join(OUTSIDE, 'secret.txt')], { encoding: 'utf8' });
782 check('while that same file is perfectly readable with nothing fencing it',
783 unfenced.status === 0 && (unfenced.stdout || '').includes(OUTSIDE_NONCE),
784 `exit ${unfenced.status}`);
785 const allowed = await run(s, { argv: ['/bin/cat', path.join(MARKED_ABS, 'inside.txt')], timeout_ms: 30000 });
786 check('while the same command on a file inside the fence succeeds',
787 allowed.includes(INSIDE_NONCE) && /\[exit code: 0\]/.test(allowed), allowed.slice(0, 200));
788
789 // ── THE MARK IS THE FENCE, NOT THE GRANT ───────────────────────
790 //
791 // `unmarked.txt` sits in the folder the user granted the hand — the same folder
792 // `root.txt` names, the one every fence path is built from — and it is not in
793 // the folder they marked into this chat. So the kernel must refuse it exactly
794 // as it refuses the file outside the grant altogether.
795 //
796 // Without this the run cannot tell the two designs apart: a fence drawn round
797 // the whole grant passes every other check in this file, which is what the
798 // fixture was written against before 5389864 and what it would silently drift
799 // back to. The control beside it is the check above, which reads a file inside
800 // the mark with the same `cat` in the same chat.
801 const grantedNotMarked = await run(s,
802 { argv: ['/bin/cat', path.join(GRANT, 'unmarked.txt')], timeout_ms: 30000 });
803 check('a file in the GRANT but outside the MARK is refused by the kernel too',
804 /Permission denied/i.test(grantedNotMarked), grantedNotMarked.slice(0, 300));
805 check('and that nonce never reached the model either',
806 !grantedNotMarked.includes(UNMARKED_NONCE), grantedNotMarked.slice(0, 200));
807 const unfenced2 = spawnSync('/bin/cat', [path.join(GRANT, 'unmarked.txt')], { encoding: 'utf8' });
808 check('and it too is perfectly readable with nothing fencing it',
809 unfenced2.status === 0 && (unfenced2.stdout || '').includes(UNMARKED_NONCE),
810 `exit ${unfenced2.status}`);
811
812 // ── A program outside the fence is refused before it runs ───────
813 //
814 // The other flavour of refusal, and the one that should NOT look like output:
815 // the hand vets `argv[0]` against the plan and says so in its own words.
816 const outsideProg = await run(s, { argv: [HAND, '--version'], timeout_ms: 30000 });
817 check('a program outside the fence is refused in the hand\'s own words',
818 /^Refused:/.test(outsideProg) && /fence/i.test(outsideProg), outsideProg.slice(0, 300));
819 check('and nothing of it ran', !/exit code/.test(outsideProg), outsideProg.slice(0, 200));
820
821 // ── The journal on disk ─────────────────────────────────────────
822 const files = fs.readdirSync(JOURNAL).filter((f) => /^hand-\d+\.jsonl$/.test(f));
823 const lines = files.flatMap((f) =>
824 fs.readFileSync(path.join(JOURNAL, f), 'utf8').split('\n').filter(Boolean));
825 check('the hand wrote a journal beside root.txt', files.length > 0, files.join(' '));
826 check('and it names the command that was run',
827 lines.some((l) => l.includes('/bin/cat') && l.includes('inside.txt')),
828 `${lines.length} entries`);
829 check('and records the refusal as well as the runs',
830 lines.some((l) => /refus/i.test(l)), `${lines.length} entries`);
831 // The hand's own record of WHERE it was told to work, which is the one account
832 // of the working directory that the app did not write. Every exec it was sent
833 // names the marked folder or something under it, and none names the granted
834 // root itself — a fence round the grant would have started them all there.
835 const cwds = lines
836 .map((l) => (/"cwd"\s*:\s*"([^"]*)"/.exec(l) || [])[1])
837 .filter((c) => !!c);
838 // Composed from the root the HAND reported, not from this file's idea of where
839 // the scratch tree is: the two differ the moment anything in the path is a
840 // symlink, which is why `st.root` is compared against a realpath above.
841 const wantCwd = `${st.root}/${MARKED}`;
842 check('and the hand\'s own journal says every command ran in the marked folder',
843 cwds.length > 0 && cwds.every((c) => c === wantCwd || c.indexOf(wantCwd + '/') === 0),
844 JSON.stringify(cwds));
845 note(`${lines.length} journal entries in ${files.join(', ')}`);
846
847 // ── The headline: a real cargo test ─────────────────────────────
848 if (NO_CARGO) {
849 note('skipping the cargo case (--no-cargo)');
850 } else {
851 const planted = plantCargoProject();
852 if (!planted) {
853 check('a real cargo test runs to completion and reaches the daimon', false,
854 'the toolchain could not be hard-linked into the granted folder');
855 } else {
856 const c = await run(s, {
857 argv: ['../toolchain/bin/cargo', 'test', '--offline'],
858 cwd: `${MARKED}/proj`, timeout_ms: 600000,
859 }, 660000);
860 check('a real cargo test compiled and ran inside the fence',
861 /test result: ok\. 1 passed/.test(c), c.slice(0, 600));
862 check('and the daimon was handed the test it actually ran',
863 c.includes('the_nonce_survives_a_real_compile'), c.slice(0, 600));
864 check('and a zero exit', /\[exit code: 0\]/.test(c), c.slice(-160));
865 if (!/test result: ok/.test(c)) note('cargo said: ' + c.slice(0, 900));
866 }
867 }
868
869 // 502s are the gateway proxy answering for a gateway nobody started; the
870 // browser-only tiers carry on without it, which is what dev/serve.mjs says.
871 const noise = s.errs.filter((e) => !/favicon|ERR_ABORTED|502|Bad Gateway/i.test(e));
872 check('the page threw nothing along the way', noise.length === 0, noise.slice(0, 3).join(' | '));
873} finally {
874 netStop = true;
875 if (netWatch) await netWatch.catch(() => {});
876 await b.close().catch(() => {});
877 for (const p of started) { try { p.kill(); } catch (e) { /* already gone */ } }
878 // Chrome kills the host when the port dies; say so if one is still standing.
879 const stray = spawnSync('pgrep', ['-fa', 'daimond-hand'], { encoding: 'utf8' });
880 if (stray.status === 0 && (stray.stdout || '').trim()) {
881 console.log(' · a hand process outlived the browser:\n' + stray.stdout.trim());
882 }
883 if (KEEP) {
884 console.log(' · scratch kept at ' + BASE);
885 } else {
886 // The toolchain here is a hard-link farm: removing it removes links and
887 // no data. Nothing under BASE is anybody's but this test's.
888 fs.rmSync(BASE, { recursive: true, force: true });
889 }
890}
891
892console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
893if (BREAK) {
894 console.log(bad.length
895 ? `\nbreak '${BREAK}' produced failures, as it must.`
896 : `\nBREAK '${BREAK}' CHANGED NOTHING — the check it targets is not proving anything.`);
897 process.exit(bad.length ? 0 : 1);
898}
899process.exit(bad.length ? 1 : 0);