Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_handrun.mjs

41.4 KiB, 1 run

created by r2519314175:473, 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_handrun.mjs — a daimon runs a command on the machine, end to end.
2//
3// `dev/verify_hand.mjs` drives the EXTENSION's relay from a stub page: it proves
4// order, attribution, gaps and the several ways a native host can vanish. It
5// never loads the app, so it proves nothing about the half a user actually
6// meets — the model calling `run`, the page relay carrying it, and the output
7// coming back as a tool result the model reads. That is this file.
8//
9// The whole path, in one browser: sign in, connect the mock provider, ask a
10// daimon to run a command, approve the machine hand for real in the extension's
11// own window, and then read what the MODEL was shown. The assertions are made
12// against the mock provider's log rather than against the screen, because the
13// question is not "did something appear" but "did the output and the exit code
14// reach the model".
15//
16// It runs against `hand/install/mock_host.py`, not the Rust binary. The host's
17// message loop is being written; more to the point, the failures worth testing —
18// a hand that says something meaningless for ever, a hand that says nothing at
19// all for a minute, a hand that dies mid-command — are things a correct hand
20// will never do, so a correct hand cannot be used to test them.
21//
22// WHAT THIS DOES NOT PROVE. The mock runs nothing. It invents output on a
23// schedule and reports whatever exit status it was configured with, so a pass
24// here says the pipeline carries a command's output and status faithfully from
25// the host to the model. It does NOT say that `cargo test` ran, or that the
26// fence held: nothing in this file executes a process, and the fence is the
27// hand's to enforce and `hand/REVIEW.md`'s to argue about.
28//
29// ── A CHAT HAS A WORKSPACE, and a command runs where the user marked ─
30//
31// Rewritten on 2026-08-13. From 5389864 a chat's commands run only in the
32// folders the user marked into that chat's workspace, and this file drove a
33// chat that had marked in nothing — so `Tool::Run` refused every command on the
34// `default_cwd` path, in its own words, BEFORE the fence was ever consulted, and
35// thirteen checks went red against the world as it used to be rather than
36// against a defect. The refusal was right; the fixture was out of date.
37//
38// So the run now has two halves, and each is worth exactly as much as the other:
39//
40// * WITH NOTHING MARKED IN, a command is refused and the sentence says what to
41// do about it — and the host is never asked to exec anything at all.
42// * WITH A FOLDER MARKED IN, through the control a person presses, every
43// command below runs, AND THE HOST'S OWN LOG SAYS IT WAS DISPATCHED INTO
44// THAT FOLDER, with a fence naming it and nothing else.
45//
46// The second half is what makes the first mean anything. A refusal on its own is
47// also what a wholly broken pipeline produces, which is how `verify_scope`'s
48// compartment checks stayed green through an outage on 2026-08-12: nothing could
49// read anything, so every "it cannot reach that" passed.
50//
51// The folder is marked in through the `+` in the chat footer's workspace group —
52// the app's own control, driven as a person drives it — and the folder it offers
53// is a folder that really is in the page's workspace, because the picker lists
54// what `Files.entries` lists and nothing else. It mirrors a real directory under
55// the folder the hand says it was granted, which is what makes it a place a
56// command could actually run. A fixture that instead wrote the holding onto the
57// chat record itself would be proving the fence against a world only this file
58// ever built, which is the mistake `dev/verify_scope.mjs` made.
59//
60// EACH HALF IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
61// damaged `www/js/daimond.js` to the real page through `page.route`; the run is
62// then expected to FAIL, and a break whose anchor does not match aborts rather
63// than passing quietly. Both breaks damage THE SCOPE THE PAGE ASKS FOR and never
64// the engine, which is the thing under test.
65//
66// node dev/verify_handrun.mjs --break nomark # the mark never reaches the engine
67// node dev/verify_handrun.mjs --break inventscope # the page invents a workspace
68// node dev/verify_handrun.mjs --break blindhello # the handshake deadline guesses why it passed
69// node dev/verify_handrun.mjs # and then, clean
70//
71// Needs nothing running: the dev server and the mock provider are started here
72// if they are not already up. Headed, under xvfb:
73// xvfb-run -a -s "-screen 0 1400x900x24" node dev/verify_handrun.mjs
74import fs from 'node:fs';
75import net from 'node:net';
76import path from 'node:path';
77import { spawn } from 'node:child_process';
78import { fileURLToPath } from 'node:url';
79
80import { open as openApp, newChat, chat, transcript, mockLog, clearMockLog, scratch } from './harness.mjs';
81import { whyStaleWasm, refuse } from './staleguard.mjs';
82
83const HERE = path.dirname(fileURLToPath(import.meta.url));
84const ROOT = path.join(HERE, '..');
85const WWW = path.join(ROOT, 'www');
86// The SHIPPED extension. `harness.open` hands this to `dev/extdev.mjs`, which
87// loads the development build instead: the shipped manifest names one origin,
88// `daimond.oxedyne.com`, and a page on localhost cannot reach it at all. The
89// loopback origins live in the generated tree and never in the file a release
90// is carved from — see extdev.mjs, and `hand/REVIEW.md` §1.6 for why.
91const SRC = path.join(ROOT, 'ext');
92const EXTID = 'mpliijponglmmffjnonahhignkpkhmij';
93const INSTALL = path.join(ROOT, 'hand/install');
94const MOCK = path.join(INSTALL, 'mock_host.py');
95const CFG = path.join(INSTALL, 'mock_cfg.json');
96// Everything the mock host was sent and everything it sent back, in its own
97// words. It is the only oracle in this file that is not the app talking about
98// itself, which is what makes it the right place to ask where a command was
99// dispatched to.
100const HOSTLOG = path.join(INSTALL, 'mock_host.log');
101const PROFILE = scratch('verify-handrun');
102
103// The folder the hand claims it was granted. Nothing is written there — the
104// mock runs nothing — but it must be absolute, because `Tool::run` refuses a
105// root that is not, and every fence path is built from it.
106const GRANT = scratch('handroot');
107// THE FOLDER THE USER MARKS INTO THE CHAT'S WORKSPACE, in two places at once,
108// because that is what one folder is in this app: a directory under the granted
109// root, which is where a command would run, and an entry in the page's own
110// workspace, which is what the picker lists and what the mark is made against.
111// A name in only one of the two is a name for nothing.
112const MARKED = 'marked';
113const MARKED_ABS = path.join(GRANT, MARKED);
114
115const BREAK = (() => {
116 const i = process.argv.indexOf('--break');
117 return i > 0 ? String(process.argv[i + 1] || '') : '';
118})();
119
120// Both damage the page's answer to "what did the user mark into this chat?", and
121// neither touches the engine that acts on it.
122const BREAKS = {
123 // The mark is made, the footer draws it, and the engine is handed nothing —
124 // which is what the app did for every chat before the mark existed, and what a
125 // caller does who forgets that `ws` is the field the fence is built from. The
126 // refusal half stays green; everything that needs a folder goes red.
127 nomark: {
128 file: 'js/daimond.js',
129 find: ` .filter(function (a) { return !!a.ws; })`,
130 with: ` .filter(function (a) { return false && !!a.ws; })`,
131 },
132 // The handshake's deadline spent without ever asking what it was spent ON --
133 // which is what it did until 2026-08-28. The two sentences collapse into one,
134 // and the wait is never extended, so a person still reading the consent window
135 // loses the command. Nothing else in the file touches this path.
136 blindhello: {
137 file: 'js/hand.js',
138 find: ` rec.timer = setTimeout(function () { helloLate(rec); }, HELLO_WAIT);`,
139 with: ` rec.timer = setTimeout(function () { drop(rec, rec.note || 'The machine hand `
140 + `was asked to start and did not answer. The approval window may still be waiting.'); }, HELLO_WAIT);`,
141 },
142 // The other direction, and the dangerous one: the page hands over a folder
143 // nobody marked in. Every command then runs, including the ones sent by a chat
144 // whose workspace is empty — so the refusal half goes red and nothing else
145 // does, which is exactly the check that half is for.
146 inventscope: {
147 file: 'js/daimond.js',
148 find: ` if (trashed(chatId)) return [];`,
149 with: ` if (trashed(chatId)) return [];\n\t\treturn ['${MARKED}'];`,
150 },
151};
152
153const ok = [], bad = [];
154const check = (name, pass, detail) => {
155 (pass ? ok : bad).push(name);
156 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
157};
158
159const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
160
161/// Serve one deliberately damaged file in place of the real one, before the app
162/// is ever loaded. An anchor that does not match exactly once aborts the run: a
163/// break that broke nothing would leave a green summary meaning the opposite of
164/// what it says.
165async function installBreak(page) {
166 if (!BREAK) return;
167 const spec = BREAKS[BREAK];
168 if (!spec) {
169 console.error(`--break ${BREAK}: no such break. One of: ${Object.keys(BREAKS).join(', ')}`);
170 process.exit(2);
171 }
172 const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8');
173 const n = src.split(spec.find).length - 1;
174 if (n !== 1) {
175 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
176 + 'so nothing was broken and the run below would prove nothing.');
177 process.exit(2);
178 }
179 const body = src.replace(spec.find, spec.with);
180 await page.route('**/' + spec.file,
181 (r) => r.fulfill({ status: 200, contentType: 'application/javascript', body }));
182}
183
184// ── The two servers ─────────────────────────────────────────────────
185//
186// Both, or nothing works and the reason is invisible: without the mock provider
187// every model turn fails and the transcript says only that Daimond could not
188// answer, which reads as a broken app rather than a missing server.
189
190function listening(port) {
191 return new Promise((resolve) => {
192 const s = net.connect(port, '127.0.0.1');
193 s.once('connect', () => { s.destroy(); resolve(true); });
194 s.once('error', () => resolve(false));
195 });
196}
197
198const started = [];
199async function serve(name, args, port) {
200 if (await listening(port)) { console.log(` (${name} already up on ${port})`); return; }
201 const p = spawn('node', args, { cwd: ROOT, stdio: 'ignore', detached: false });
202 started.push(p);
203 for (let i = 0; i < 100; i++) {
204 if (await listening(port)) { console.log(` (started ${name} on ${port})`); return; }
205 await sleep(100);
206 }
207 throw new Error(`${name} did not come up on ${port}`);
208}
209
210// ── The mock hand ───────────────────────────────────────────────────
211
212const HOSTS = path.join(PROFILE, 'NativeMessagingHosts');
213
214/// Point the profile's native messaging host at the mock, with the behaviour
215/// this case needs. The host reads its configuration when it starts, so a fresh
216/// link picks up a fresh setting — which is why every case closes the link.
217function register(cfg) {
218 fs.mkdirSync(HOSTS, { recursive: true });
219 fs.writeFileSync(path.join(HOSTS, 'com.oxedyne.daimond.hand.json'), JSON.stringify({
220 name: 'com.oxedyne.daimond.hand',
221 description: 'Mock hand for verify_handrun.mjs.',
222 path: MOCK,
223 type: 'stdio',
224 allowed_origins: [`chrome-extension://${EXTID}/`],
225 }, null, '\t') + '\n');
226 fs.writeFileSync(CFG, JSON.stringify(Object.assign({
227 // What a Linux hand with a working fence reports, plus the granted
228 // folder. The folder arrives as a CAPABILITY because `wire.rs` has no
229 // field for it and the wire is fixed; see ROOT_CAP in www/js/hand.js.
230 caps: ['fence:linux', 'landlock:abi-8', 'carve:sealed', `root:${GRANT}`],
231 }, cfg || {}), null, '\t') + '\n');
232}
233function unregister() {
234 try { fs.rmSync(path.join(HOSTS, 'com.oxedyne.daimond.hand.json')); } catch (e) { /* gone */ }
235}
236
237// ── What the model was shown ────────────────────────────────────────
238
239/// The last tool result the model was sent, which is the whole point of the
240/// exercise: a run that draws output on screen and hands the model nothing has
241/// achieved nothing.
242function toolResult() {
243 const reqs = mockLog();
244 for (let i = reqs.length - 1; i >= 0; i--) {
245 const msgs = reqs[i].messages || [];
246 for (let j = msgs.length - 1; j >= 0; j--) {
247 if (msgs[j].role === 'tool') return String(msgs[j].content || '');
248 }
249 }
250 return '';
251}
252
253// ── What the HOST was asked to do ───────────────────────────────────
254//
255// The mock appends every frame it receives to `mock_host.log`, which is the one
256// record in this run that the app did not write. Two things are asked of it that
257// nothing else here can answer: whether a refused command was really never
258// dispatched, and which directory a dispatched one was told to run in.
259//
260// The file is beside the mock and therefore SHARED — every world's runs append to
261// the same one — so this reads only what was appended after this run started and
262// keeps only the frames naming this run's granted root, which carries the world
263// number in its path.
264const logFrom = (() => { try { return fs.statSync(HOSTLOG).size; } catch (e) { return 0; } })();
265
266/// Every `exec` the model's own `run` tool caused, as `{ id, cwd, line }`.
267///
268/// Keyed on the id `Tool::run_id` composes (`run-<n>-<program>`), so the one exec
269/// this file drives through the relay by hand at the end — `r-reload`, which never
270/// goes near `Tool::Run` — is not counted as one of the model's.
271///
272/// `cwd` is read with a regex rather than by parsing: the mock truncates each
273/// logged frame at 400 characters, and a fence carrying several roots can reach
274/// that. The working directory is near the front and always survives.
275function execsSent() {
276 let text = '';
277 try {
278 const fd = fs.openSync(HOSTLOG, 'r');
279 const size = fs.fstatSync(fd).size;
280 const buf = Buffer.alloc(Math.max(0, size - logFrom));
281 if (buf.length) fs.readSync(fd, buf, 0, buf.length, logFrom);
282 fs.closeSync(fd);
283 text = buf.toString('utf8');
284 } catch (e) { return []; }
285 return text.split('\n')
286 .filter((l) => /<- \{"t": "exec"/.test(l) && l.includes(GRANT))
287 .map((l) => ({
288 id: (/"id": "([^"]*)"/.exec(l) || [])[1] || '',
289 cwd: (/"cwd": "([^"]*)"/.exec(l) || [])[1] || '',
290 line: l,
291 }))
292 .filter((e) => e.id.indexOf('run-') === 0);
293}
294
295// ── The bundle this file is actually asking questions about ─────────
296//
297// The wasm the browser loads is what composes every `run` request the host is
298// sent, decides where a command may start, and writes the refusals asserted
299// below. It is as much the code under test as the page's JavaScript is.
300//
301// This guard was in `verify_handreal.mjs` and NOT here, and the asymmetry was
302// the defect: a pair where one half refuses a stale bundle and the other half
303// measures it silently means the unguarded half reports on a build nobody
304// intended, and reports it green. Three lanes were misled that way on
305// 2026-08-12. It is asked before the servers are started, so a refusal costs no
306// processes and leaves nothing to clean up.
307refuse(whyStaleWasm(path.join(ROOT, 'www/pkg/oxedyne_daimond_bg.wasm'), path.join(ROOT, 'src'), {
308 subject: 'What the app asks the hand for',
309 holds: 'every tool call this file makes',
310}));
311
312fs.rmSync(PROFILE, { recursive: true, force: true });
313fs.mkdirSync(PROFILE, { recursive: true });
314// The granted root, and inside it the one folder this run's chat will be given.
315// It is a real directory: a mark on a folder that is not there would be a mark on
316// nothing, and the app would be the only thing that ever believed in it.
317fs.mkdirSync(MARKED_ABS, { recursive: true });
318register({ chunks: 3 });
319
320// What the children will bind: `serve.mjs` reads DAIMOND_PORT and `mockllm.mjs`
321// DAIMOND_MOCK_PORT, so the wait below is asking about the port they chose.
322const APP_PORT = Number(process.env.DAIMOND_PORT || 8777);
323const MOCK_PORT = Number(process.env.DAIMOND_MOCK_PORT || 9099);
324await serve('dev server', ['dev/serve.mjs'], APP_PORT);
325await serve('mock provider', ['dev/mockllm.mjs'], MOCK_PORT);
326
327// Signed in, pointed at the mock provider, with the extension loaded — the app
328// as a user meets it. Headed and on a fixed profile, because the host manifest
329// above was written into that profile's own NativeMessagingHosts directory,
330// which is where a browser started with --user-data-dir looks for it.
331const s = await openApp({
332 headed: true, name: 'handrun', extension: SRC, profile: PROFILE, route: installBreak,
333});
334const b = s.browser;
335const page = s.page;
336
337/// Find the grant window and click Allow, in the background, while the turn
338/// that provoked it is still running. It is the extension's own page, so the
339/// click is a real one — and for this question there is no second Chrome prompt
340/// behind it: this window IS the approval.
341async function allowHand(ms = 20000) {
342 const until = Date.now() + ms;
343 while (Date.now() < until) {
344 for (const p of b.pages()) {
345 if (/grant\.html/.test(p.url())) {
346 await p.waitForLoadState('domcontentloaded').catch(() => {});
347 await sleep(300);
348 const head = await p.evaluate(() =>
349 (document.getElementById('head') || {}).textContent || '').catch(() => '');
350 await p.click('#allow').catch(() => {});
351 return head;
352 }
353 }
354 await sleep(150);
355 }
356 return null;
357}
358
359// ── The turn's network question ─────────────────────────────────────
360//
361// A chat that has read a command's output is TAINTED from that moment, so the
362// engine asks before the NEXT command may reach the network (`hand/REVIEW.md`
363// §1.13, `Tool::run`'s `NetStep::Ask`) and holds the turn on a modal until
364// somebody answers. Nothing in this file answered it. So from the second
365// command on, every turn stopped on that dialog until `chat`'s own 60-second
366// timeout expired, `chat` returned with the turn still running, and the check
367// below read the tool result of THE COMMAND BEFORE — one whole command out of
368// step. That is the whole of what "a non-zero exit reaches the model as itself"
369// was reporting on 2026-08-17: not an exit code carried wrongly, but the exit
370// code of the previous run, read a minute too early. The turn after it lost its
371// user message as well, to a click that landed on the modal's backdrop, so the
372// gap case measured `make`'s output under `patchy`'s configuration.
373//
374// The answer is NO, which is the fence every run below has always been measured
375// against: they carry the engine's "no network" note, and a yes would silently
376// change what each of them ran with.
377// READ FROM THE APP, not copied out of it. This was the literal string, and on
378// 2026-08-19 `permmode.net_title` changed -- it said "this turn" and meant this
379// chat -- which would have left the watcher below never recognising the dialog
380// and therefore never answering it. That is not a red: it is the failure of
381// 2026-08-18, where an unanswered network dialog made `chat()` time out with the
382// turn still running and every assertion after it read ONE COMMAND LATE. A
383// verifier that silently measures the wrong command is worse than one that stops.
384//
385// `t()` falls back to the key's own name if the key is gone, which no dialog will
386// ever match, so a DELETED key stops this loudly instead of quietly.
387const netTitle = async (page) => await page.evaluate(() =>
388 (window.DaimondI18n ? DaimondI18n.t('permmode.net_title') : 'permmode.net_title'));
389let netAsked = 0;
390let netStop = false;
391let netWatch = null;
392
393/// Say no to the network question for as long as this run lasts, and count how
394/// often it was put.
395async function answerNet(page) {
396 while (!netStop) {
397 const asked = await page.evaluate((title) => {
398 for (const card of document.querySelectorAll('.dlg-card')) {
399 const h = card.querySelector('h2');
400 if (h && h.textContent.indexOf(title) >= 0) return true;
401 }
402 return false;
403 }, await netTitle(page)).catch(() => false);
404 // PRESSED, rather than resolved from inside the page: the button is what
405 // a user has, and a question answered by reaching past it proves nothing
406 // about the one they are actually shown.
407 if (asked) {
408 const said = await page.click('.dlg-card .dlg-cancel', { timeout: 2000 })
409 .then(() => true, () => false);
410 if (said) netAsked++;
411 }
412 await sleep(200);
413 }
414}
415
416try {
417 await sleep(500);
418
419 check('the extension announced itself to the app',
420 await page.evaluate(() => !!document.documentElement.dataset.daimondHands));
421 check('the page relay is loaded and wired',
422 await page.evaluate(() => !!(window.DaimondHand && window.DaimondHand.run)));
423
424 /// The waits are tens of seconds by design; a test cannot spend them.
425 async function waits(o) {
426 return await page.evaluate((x) => window.DaimondHand._setWaitsForTest(x), o);
427 }
428 /// Let go of the link, so the next case gets a fresh host with fresh
429 /// configuration. The relay keeps one port for the life of the page.
430 async function relink() {
431 await page.evaluate(() => window.DaimondHand.close());
432 await sleep(400);
433 }
434
435 await waits({ grace: 4000, slack: 2000, hello: 15000 });
436 netWatch = answerNet(page);
437
438 // The chat every turn below is sent to, and therefore the chat whose workspace
439 // decides where its commands may run. Opened before anything is asked of it,
440 // because the folder is marked into THIS chat and a second one would have an
441 // empty workspace of its own.
442 await newChat(s);
443 await sleep(400);
444 const focus = await page.evaluate(() => window.DaimondAttach.focus());
445 const chatId = focus && focus.id;
446 check('a chat is in focus, so there is a workspace to mark a folder into',
447 !!chatId && focus.kind === 'chat', JSON.stringify(focus));
448
449 // The other half of the folder. `MARKED_ABS` is a directory on the machine;
450 // this is the same folder in the workspace the PAGE holds, which in a harness
451 // is OPFS — no browser can be made to answer `showDirectoryPicker()`. It is
452 // laid down through the tool door, which is how a turn would have made it, and
453 // it is what puts the folder in front of the picker below: `Files.entries` is
454 // the panel's own listing and lists nothing that is not there.
455 await page.evaluate(async (dir) => {
456 const m = await import('/pkg/oxedyne_daimond.js');
457 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
458 await app.run_tool('dir_create', JSON.stringify({ path: dir }));
459 }, MARKED);
460
461 // ── Half one: nothing marked in, and the model is told why ──────
462 //
463 // The first turn, so it is also the turn that provokes the grant window. The
464 // chat's workspace holds its own scratch and nothing else, its scratch is in
465 // the browser's storage and not a place on this computer, and there is
466 // therefore nowhere for a command to run. `Tool::Run` says so on the
467 // `default_cwd` path, above the fence and above the hand.
468 clearMockLog();
469 const grant = allowHand();
470 await chat(s, '@tool run {"argv":["cargo","test"],"timeout_ms":20000}', { timeout: 60000 });
471 const head = await grant;
472 check('running a command asks the user first, in the extension\'s own window',
473 !!head && /computer/i.test(head), String(head));
474
475 // The belt the model was actually offered, taken from what the provider was
476 // sent rather than from a list in the page.
477 const belt = (mockLog()[0] || {}).tools || [];
478 check('`run` is in the toolbelt the model is offered', belt.includes('run'), belt.join(' '));
479
480 let r = toolResult();
481 check('WITH NOTHING MARKED IN, a command is refused rather than run',
482 /^Refused: /.test(r) && /holds nothing on this computer/.test(r), r.slice(0, 200));
483 check('and the sentence says what to do about it, and where',
484 // The CONTROL, and the right one. `/paperclip/` was green while the
485 // paperclip attaches for READING and grants no writing -- so the sentence
486 // sent the user to a button that changed nothing. The negative half is the
487 // one that matters: naming the right control is no use while the wrong one
488 // is still named beside it.
489 /\+ in the Workspace group/.test(r) && !/paperclip/.test(r)
490 && /mark it into this chat's workspace/.test(r), r.slice(0, 400));
491 // The refusal a chat gets and the refusal a Diamond gets are different
492 // sentences on purpose (`ToolContext::is_chat_scoped`), and a model handed the
493 // wrong one is sent to a panel that is not where a chat's workspace is changed.
494 // Asserted as a property OF THE REFUSAL — `!/Diamond/` is also true of a
495 // command's output, so a check that only looked for the absence of the word
496 // would pass in exactly the case where there is no refusal to describe.
497 check('and it is the CHAT\'s words: no Diamond, no Workspace panel',
498 /^Refused: /.test(r) && !/Diamond/.test(r), r.slice(0, 300));
499 check('and nothing was dispatched — the host was never asked to run anything',
500 execsSent().length === 0, JSON.stringify(execsSent().map((e) => e.cwd)));
501
502 // ── The user marks a folder in, with the control that does it ───
503 //
504 // The `+` in the footer's workspace group: the one control whose whole job is
505 // to put a folder into this chat's workspace, driven through its dialog as a
506 // person drives it. Not `DaimondAttach.chatWs`, which would set the field and
507 // prove only that the field exists — the press is the permission, and a press
508 // that reached nothing is one of the two defects this app was rebuilt over.
509 await page.click('#chat-attachments .ws-group [data-act="attach-add"]', { force: true });
510 await page.waitForSelector('.attach-pick-row', { timeout: 10000 });
511 const ticked = await page.evaluate((name) => {
512 const row = [...document.querySelectorAll('.attach-pick-row')]
513 .find((x) => ((x.querySelector('.attach-pick-name') || {}).textContent || '').indexOf(name) >= 0);
514 if (!row) return [...document.querySelectorAll('.attach-pick-name')]
515 .map((x) => x.textContent).join(', ') || 'the picker listed nothing';
516 row.querySelector('input').click();
517 return 'ticked';
518 }, MARKED);
519 check('the folder is in the page\'s own workspace, for the picker to offer',
520 ticked === 'ticked', ticked);
521 await page.click('.dlg-ok', { force: true });
522 await sleep(1000);
523 const scope = await page.evaluate((id) => window.DaimondAttach.chatScope(id), chatId);
524 check('MARKING IT IN is what the engine is handed as this chat\'s workspace',
525 Array.isArray(scope) && scope.indexOf(MARKED) >= 0, JSON.stringify(scope));
526
527 // ── Half two: a command runs, and its output reaches the model ──
528 clearMockLog();
529 await chat(s, '@tool run {"argv":["cargo","test"],"timeout_ms":20000}', { timeout: 60000 });
530 r = toolResult();
531 check('the command\'s output reached the model',
532 /line 1 of cargo test/.test(r) && /line 3 of cargo test/.test(r), r.slice(0, 200));
533 check('so did what it wrote on standard error',
534 /\[stderr\] a word from standard error/.test(r), r.slice(0, 300));
535 check('and the exit code', /\[exit code: 0\]/.test(r), r.slice(-120));
536 check('the output is marked as a stranger\'s words, naming the command',
537 /untrusted content begins — run: cargo test/.test(r), r.slice(0, 120));
538 check('the person watching saw it too, as it arrived',
539 /line 1 of cargo test/.test(await transcript(s)), '');
540
541 // ── And it ran WHERE THE USER MARKED, which is the whole claim ──
542 //
543 // Asked of the host's own log rather than of the app: "output came back" is
544 // true of a command dispatched anywhere, and of a pipeline that carries
545 // invented text between two halves of the same page. The working directory and
546 // the fence are the two fields that say the mark reached the wire.
547 const sent = execsSent();
548 check('the command was dispatched into the folder the user marked in',
549 sent.length > 0 && sent.every((e) => e.cwd === MARKED_ABS),
550 JSON.stringify(sent.map((e) => e.cwd)));
551 check('and fenced to that folder, not to the whole granted root',
552 sent.length > 0 && sent.every((e) => e.line.includes(`"fence": {"rw": ["${MARKED_ABS}"]`)),
553 (sent[sent.length - 1] || {}).line || 'nothing was sent');
554
555 // ── A failure is reported as a failure ──────────────────────────
556 await relink();
557 register({ chunks: 1, exit: 3 });
558 clearMockLog();
559 await chat(s, '@tool run {"argv":["make"],"timeout_ms":20000}', { timeout: 60000 });
560 // A COMMAND FENCED WHERE NOTHING UNTRUSTED LIVES KEEPS THE TURN'S NETWORK, and that is
561 // the owner's decision of 2026-08-24, not a defence being dropped.
562 //
563 // This check asserted the opposite until then, because every `run` tainted the turn
564 // unconditionally: the envelope round a command's output and the loss of the network
565 // were one call, and only the envelope was ever argued for. What that cost was measured
566 // on a real development run -- 26 of 30 tool results carried `[no network: …]`, 18.5% of
567 // the bytes the daimon read, and the turn lost its network to its own first `grep` of
568 // the owner's own source, while `egress_check` fired ZERO times because a daimon doing
569 // source work calls no web tool at all. The permission dialog was never the cost; the
570 // withheld network was.
571 //
572 // So the taint now follows the FENCE: a command whose fence could reach a stranger's
573 // words costs the turn its network, and one fenced to a folder the user marked does not.
574 // The fence here is that marked folder, so no question is right. The other half -- a
575 // fence with the mailbox in it still costing the network -- is held in Rust by
576 // `test_a_command_whose_fence_reaches_the_mailbox_still_takes_the_network_away`
577 // (src/tools.rs) and in the browser by `dev/verify_daimonreach.mjs`.
578 check('a command fenced where nothing untrusted lives does NOT cost the turn its network',
579 netAsked === 0, 'the question was put ' + netAsked + ' time(s)');
580 r = toolResult();
581 check('a non-zero exit reaches the model as itself',
582 /\[exit code: 3\]/.test(r) && !/exit code: 0/.test(r), r.slice(-160));
583
584 // ── A hole in the stream is shown to the model ──────────────────
585 //
586 // The first chunk of a stream sets the baseline — where a hand starts
587 // counting is its own business — so this is what proves the marker still
588 // fires when there is a real hole rather than merely a different origin.
589 await relink();
590 register({ chunks: 3, gap: true });
591 clearMockLog();
592 await chat(s, '@tool run {"argv":["patchy"],"timeout_ms":20000}', { timeout: 60000 });
593 r = toolResult();
594 check('a hole in the output is shown to the model, not stitched over',
595 /output missing: expected chunk/.test(r), r.slice(0, 300));
596 await relink();
597 register({ chunks: 2 });
598 clearMockLog();
599 await chat(s, '@tool run {"argv":["tidy"],"timeout_ms":20000}', { timeout: 60000 });
600 r = toolResult();
601 check('and an ordinary run carries no such marker', !/output missing/.test(r), r.slice(0, 200));
602
603 // ── §4.4 The output is bounded, and says where it was cut ───────
604 await relink();
605 register({ chunks: 3000 });
606 await waits({ keep: 400 });
607 clearMockLog();
608 await chat(s, '@tool run {"argv":["flood"],"timeout_ms":20000}', { timeout: 60000 });
609 r = toolResult();
610 check('a command that prints too much does not go unbounded into the tab',
611 r.length < 20000, `${r.length} chars`);
612 check('and the hole is named where it happened, not smoothed over',
613 /characters of output are missing here/.test(r), r.slice(0, 200));
614 check('both ends of the output are kept: the start …',
615 /line 1 of flood/.test(r), '');
616 check('… and the end, which is where a build says why it failed',
617 /line 3000 of flood/.test(r), r.slice(-200));
618 await waits({ keep: 262144 });
619
620 // ── §4.3 A quiet command is not a dead one ──────────────────────
621 //
622 // The grace is four seconds here and the host says nothing for six after
623 // `started`. Under the old rule — the wait refreshed only by output — this
624 // was rejected as "stopped part-way through the command" while the process
625 // was still running, which is exactly the `cargo test` case.
626 await relink();
627 register({ chunks: 2, quiet_ms: 6000 });
628 clearMockLog();
629 await chat(s, '@tool run {"argv":["cargo","test"],"timeout_ms":30000}', { timeout: 90000 });
630 r = toolResult();
631 check('a command that says nothing for longer than the grace still finishes',
632 /line 1 of cargo test/.test(r) && /\[exit code: 0\]/.test(r), r.slice(0, 200));
633
634 // ── §4.2 Noise is not proof of life ─────────────────────────────
635 //
636 // The host sends a message the page does not understand, three times a
637 // second, and nothing else at all. Anything that refreshes the wait on
638 // receipt of a message rather than on receipt of a MEANINGFUL one waits for
639 // ever here, and the daimon never speaks again.
640 await relink();
641 register({ noise_ms: 300 });
642 clearMockLog();
643 const t0 = Date.now();
644 await chat(s, '@tool run {"argv":["noisy"],"timeout_ms":600000}', { timeout: 60000 });
645 const took = Date.now() - t0;
646 r = toolResult();
647 check('a host that says only meaningless things does not hold the model for ever',
648 took < 30000, `${took} ms, grace 4000`);
649 check('and the daimon is told what happened, in one plain sentence',
650 /^Refused: /.test(r) && /did not acknowledge/.test(r), r.slice(0, 200));
651 check('nothing was invented about a command that never started',
652 !/exit code/.test(r), r.slice(0, 200));
653
654 // ── §1.16 A hand that dies is not a hand that was never there ───
655 await relink();
656 register({ crash: true });
657 clearMockLog();
658 await chat(s, '@tool run {"argv":["boom"],"timeout_ms":20000}', { timeout: 60000 });
659 r = toolResult();
660 check('a hand that crashes mid-command says so',
661 /disconnected|stopped|crash/i.test(r), r.slice(0, 300));
662 check('and does NOT tell the user to install what they have already installed',
663 !/not installed/i.test(r), r.slice(0, 300));
664
665 // ── Gate 1: a hand that cannot fence is refused ─────────────────
666 await relink();
667 register({ chunks: 1, caps: ['mock', `root:${GRANT}`] });
668 clearMockLog();
669 await chat(s, '@tool run {"argv":["cargo","test"]}', { timeout: 60000 });
670 r = toolResult();
671 check('a hand that does not say it can fence is refused',
672 /^Refused:/.test(r) && /fence/i.test(r), r.slice(0, 200));
673 check('and nothing ran', !/line 1 of/.test(r), r.slice(0, 200));
674
675 await relink();
676 register({ chunks: 1, caps: ['fence:none', `root:${GRANT}`] });
677 clearMockLog();
678 await chat(s, '@tool run {"argv":["cargo","test"]}', { timeout: 60000 });
679 r = toolResult();
680 check('a hand that says it CANNOT fence is refused in its own words',
681 /^Refused:/.test(r) && /cannot fence/i.test(r), r.slice(0, 200));
682
683 // ── No root, no fence to express ────────────────────────────────
684 await relink();
685 register({ chunks: 1, caps: ['fence:linux', 'landlock:abi-8'] });
686 clearMockLog();
687 await chat(s, '@tool run {"argv":["cargo","test"]}', { timeout: 60000 });
688 r = toolResult();
689 check('a hand that will not name the granted folder is refused',
690 /folder/i.test(r) && !/line 1 of/.test(r), r.slice(0, 240));
691
692 // ── Nothing installed at all ────────────────────────────────────
693 await relink();
694 unregister();
695 clearMockLog();
696 await chat(s, '@tool run {"argv":["cargo","test"]}', { timeout: 60000 });
697 r = toolResult();
698 check('a missing host is reported as a missing host',
699 /not installed/i.test(r), r.slice(0, 240));
700 check('and the sentence says exactly what to install',
701 /install\.sh/.test(r) && /com\.oxedyne\.daimond\.hand/.test(r), r.slice(0, 400));
702
703 // ── A greeting that never comes, and WHY it never came ──────────
704 //
705 // Opening the port is what raises the approval window, and the extension holds
706 // the greeting behind it -- so this one deadline is spent on a person reading a
707 // consent screen as often as on anything being wrong, and the two used to be
708 // told apart by guessing. Whatever had happened, the daimon was handed "the
709 // approval window may still be waiting", and a person who took longer than the
710 // deadline to read the strongest permission Daimond asks for had their command
711 // fail on a question they then said yes to. `hand_status` answers it -- a
712 // question with no window behind it -- and had no caller anywhere.
713 //
714 // Both branches are driven, because either sentence alone would pass on a page
715 // that simply always said that one.
716 await relink();
717 register({ mute: true });
718 await waits({ hello: 1500, status: 1500, grant: 2000 });
719 clearMockLog();
720 const tMute = Date.now();
721 await chat(s, '@tool run {"argv":["cargo","test"]}', { timeout: 60000 });
722 const muteTook = Date.now() - tMute;
723 r = toolResult();
724 check('a hand that is granted and never greets is reported as one that said nothing',
725 /said nothing/.test(r) && /^Refused: /.test(r), r.slice(0, 240));
726 check('and NOT as an approval window nobody has answered — nobody is being asked',
727 !/approval window/i.test(r), r.slice(0, 240));
728 check('and it ended rather than hanging on the extension\'s question',
729 muteTook < 45000, muteTook + ' ms');
730
731 // And now the other branch: the grant taken back, so connecting really does
732 // raise a window, and nothing here answers it. The wait must be EXTENDED rather
733 // than spent -- which is the whole fix -- and the sentence must then say that a
734 // question is waiting, because this time one is.
735 await relink();
736 await page.evaluate(() => new Promise((done) => {
737 const id = document.documentElement.dataset.daimondHands;
738 chrome.runtime.sendMessage(id, { cmd: 'hand_revoke' }, () => done(true));
739 }));
740 clearMockLog();
741 const tAsk = Date.now();
742 await chat(s, '@tool run {"argv":["cargo","test"]}', { timeout: 60000 });
743 const askTook = Date.now() - tAsk;
744 r = toolResult();
745 check('an unanswered approval window is reported as an unanswered approval window',
746 /approval window/i.test(r) && /^Refused: /.test(r), r.slice(0, 240));
747 check('and the daimon is told to have it answered rather than to install anything',
748 !/not installed/i.test(r), r.slice(0, 240));
749 // The extension is what the fix buys: the first deadline passes, `hand_status`
750 // says somebody is still being asked, and the page waits again instead of
751 // apologising. Measured against the deadline it was given, not against a clock.
752 check('and the handshake deadline was EXTENDED once while the question stood',
753 askTook > 3500, askTook + ' ms, against a 1500 ms handshake deadline');
754 // Answer it, so the case below meets the world it expects -- and then LET GO of
755 // the link the answer opens. Allowing it settles the connect this case left
756 // pending, which starts the mute host and leaves its relay parked against this
757 // tab; the next case would adopt that parked relay instead of trying to launch
758 // the host it has just unregistered, and would measure a mute host rather than
759 // a missing one. Measured: without this `relink` the missing-host case reported
760 // the handshake sentence above.
761 await allowHand(8000);
762 await relink();
763 register({ chunks: 1 });
764 await waits({ hello: 15000 });
765
766
767 // ── §1.16 again, where it actually bites ────────────────────────
768 //
769 // The crash above is announced BY the extension, which writes its own
770 // sentence, so it does not discriminate between the two behaviours. This
771 // does: the link dies with nobody saying anything, which is what an
772 // extension reload, an evicted worker or a lost pipe looks like from the
773 // page. Every disconnect used to be answered with "the hand is not
774 // installed" — advice to install software the user has already installed.
775 //
776 // Driven through the relay rather than the model, because the model's turn
777 // would have to be held open across the reload for no gain.
778 await relink();
779 register({ chunks: 5, delay_ms: 1500 });
780 // The same shape `Tool::run` composes — the extension vets the fence now
781 // (`hand/REVIEW.md` §1.5), and rightly refuses one that names no root.
782 await page.evaluate((grant) => {
783 window.__handrun = window.DaimondHand.run(JSON.stringify({
784 t: 'exec', id: 'r-reload', argv: ['sleep'], cwd: grant, env: [], stdin: null,
785 timeout_ms: 60000, capture: 'both',
786 fence: { rw: [grant], ro: [], deny: [], net: false },
787 })).then((v) => ({ ok: v }), (e) => ({ err: e.message }));
788 }, GRANT);
789 await sleep(1500);
790 const sw = b.serviceWorkers()[0];
791 if (sw) await sw.evaluate(() => chrome.runtime.reload()).catch(() => {});
792 const lost = await page.evaluate(() => window.__handrun);
793 check('a link that dies with nobody saying why is reported as a hand that STOPPED',
794 !!lost.err && /answered earlier and has now gone/.test(lost.err), JSON.stringify(lost).slice(0, 300));
795 check('and not as one that was never installed',
796 !!lost.err && !/is not installed|it is not installed/i.test(lost.err),
797 JSON.stringify(lost).slice(0, 300));
798
799 // 502s are the gateway proxy answering for a gateway nobody started; the
800 // browser-only tiers carry on without it, which is what `dev/serve.mjs` says.
801 const noise = s.errs.filter((e) => !/favicon|ERR_ABORTED|502|Bad Gateway/i.test(e));
802 check('the page threw nothing along the way', noise.length === 0, noise.slice(0, 3).join(' | '));
803} finally {
804 netStop = true;
805 if (netWatch) await netWatch.catch(() => {});
806 await b.close().catch(() => {});
807 for (const p of started) { try { p.kill(); } catch (e) { /* already gone */ } }
808 try { fs.rmSync(CFG); } catch (e) { /* never written */ }
809}
810
811console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
812if (BREAK) {
813 console.log(bad.length
814 ? `\nbreak '${BREAK}' produced failures, as it must.`
815 : `\nBREAK '${BREAK}' CHANGED NOTHING — the check it targets is not proving anything.`);
816 process.exit(bad.length ? 0 : 1);
817}
818process.exit(bad.length ? 1 : 0);