Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_search_app.mjs

45.7 KiB, 1 run

created by r2519314175:661, 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_search_app.mjs — the browser half of the search tool: the setting, the
2// key, and the pause control the Web panel never had.
3//
4// WHAT THIS FILE IS ABOUT. Two things shipped together (dev/SEARCH_CONTRACT.md
5// §8 and §9) and they share one property: a decision about money that the user
6// can see and reach.
7//
8// * WHICH ENGINE, and whose key pays. `credits` is the account's balance and
9// the gateway holds the key; everything else is the user's own key, sealed
10// under the passphrase and sent only in the request it pays for. `serper`
11// is BYOK ONLY — it resells Google, and Oxedyne billing for an arbitrage
12// that can stop without notice is a risk the contract says no to (§3).
13//
14// * `root/web` GETS ITS CONTROL. The leaf has existed since the pause tree
15// did, with no control anywhere in the app. A user hit that this morning:
16// they paused "Everything", web access stopped, and the refusal told them
17// to press play on it in a panel with no play button. The only way back was
18// to resume everything — which also resumes the Daimond Optimiser, and that
19// ships paused on purpose. So the last section below is not "does the
20// button work": it is that PLAY ON THIS ONE LEAF MOVES THIS ONE LEAF, with
21// every other leaf held, which is the state that user was actually in.
22//
23// HOW IT JUDGES. No literal counts and no `boxes[0]`. The pause section
24// compares SETS of paused ids before and after, so "and only root/web" is a set
25// difference rather than a number; the key section scans every localStorage
26// entry for the secret rather than looking in the one it expects; the engine
27// section reads the request the app was about to send, whatever engine it names.
28//
29// PROVED RED. `--unbuilt` rewrites the two files as they are served, undoing
30// each half — the Web panel's mount, the BYOK-only rule, and the promise that a
31// key is never written unsealed — and every matching check must fail there.
32//
33// node dev/verify_search_app.mjs
34// node dev/verify_search_app.mjs --unbuilt # must fail, loudly
35//
36// Needs a world: `eval "$(bash dev/world.sh N --up)"`. NO GATEWAY — the search
37// endpoint is stubbed in the page, deliberately: this file is about what the
38// browser DECIDES to send and what it does with the answer, and a real gateway
39// would make it a test of somebody else's lane.
40
41import fs from 'node:fs';
42import { open, scratch } from './harness.mjs';
43
44const UNBUILT = process.argv.includes('--unbuilt');
45
46const out = [];
47let bad = 0;
48const check = (ok, what, detail) => {
49 out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`);
50 if (!ok) bad++;
51 return ok;
52};
53const red = (wentRed, what) => check(wentRed, `[self-test] ${what}`);
54
55/// Two id lists as sets, compared by what is in them and not by their order.
56const sameSet = (a, b) => {
57 const A = new Set(a || []), B = new Set(b || []);
58 return A.size === B.size && [...A].every((x) => B.has(x));
59};
60/// What is in `a` and not in `b`.
61const minus = (a, b) => (a || []).filter((x) => !(b || []).includes(x));
62
63/// The part of the engine note that speaks about the engine now chosen: what
64/// is left once the general sentence about vendors is taken off the front.
65const engineTail = (note, general) =>
66 (/\S/.test(general) && note.indexOf(general) === 0 ? note.slice(general.length) : note);
67/// Whether that part calls the chosen engine free on its own account.
68const claimsFree = (note, general) => /\bfree\b/i.test(engineTail(note, general));
69
70/// A key that could not plausibly be anything else in a storage dump.
71const SECRET = 'searchkey-' + process.pid + '-do-not-store-in-the-clear';
72
73const profile = scratch('pw', 'searchapp-' + process.pid);
74const s = await open({ name: 'searchapp' + process.pid, profile });
75const closeBrowser = s.close;
76s.close = async () => {
77 await closeBrowser();
78 try { fs.rmSync(profile, { recursive: true, force: true }); } catch (e) { /* gone */ }
79};
80const p = s.page;
81
82// ── The unbuilt page ────────────────────────────────────────────────
83//
84// Each replacement undoes ONE thing this release added, in the served source, so
85// what runs is the app as it was rather than the app with a flag in it.
86if (UNBUILT) {
87 await p.route('**/js/daimond.js', async (route) => {
88 const res = await route.fetch();
89 let body = await res.text();
90 body = body
91 // The Web panel's control, never mounted: the leaf as it stood this morning.
92 .replace("var web = document.getElementById('web-pause');",
93 'var web = null; /* UNBUILT */')
94 // And the settings row, never drawn.
95 .replace('if (!SearchRow.mount()) return;', 'return; /* UNBUILT */')
96 // The egress arm gone, so a search falls through to the same-origin
97 // shortcut below it and is waved out with nobody asked — which is
98 // exactly what §7's guarantee was doing before this release.
99 .replace("if (req.tool === 'web_search') {", 'if (false) { /* UNBUILT */');
100 await route.fulfill({ response: res, body, headers: { ...res.headers(), 'content-type': 'text/javascript; charset=utf-8' } });
101 });
102 await p.route('**/js/search.js', async (route) => {
103 const res = await route.fetch();
104 let body = await res.text();
105 body = body
106 // serper reachable on the balance, which §3 forbids.
107 .replace('var BYOK_ONLY = { serper: true };', 'var BYOK_ONLY = {}; /* UNBUILT */')
108 // And the key written in the clear beside its sealed copy.
109 .replace("store.keys[id] = { key: '', keyEnc: sealed };",
110 'store.keys[id] = { key: k, keyEnc: sealed }; /* UNBUILT */');
111 await route.fulfill({ response: res, body, headers: { ...res.headers(), 'content-type': 'text/javascript; charset=utf-8' } });
112 });
113 await p.route('**/js/gateway.js', async (route) => {
114 const res = await route.fetch();
115 let body = await res.text();
116 // The search route unknown to the spend guard, which is how it stood: no
117 // arm matched it, so it fell out of the bottom of `spendRefusal` governed
118 // by nothing — not the leaf, not a Diamond's node, not the global control.
119 body = body.replace("|| p === '/api/web/search'", '|| false /* UNBUILT */');
120 await route.fulfill({ response: res, body, headers: { ...res.headers(), 'content-type': 'text/javascript; charset=utf-8' } });
121 });
122 await p.reload({ waitUntil: 'domcontentloaded' });
123 await p.waitForTimeout(1500);
124 const gate = await p.$('#id-pass');
125 if (gate && await gate.isVisible()) {
126 await p.fill('#id-pass', 'testpass1234');
127 await p.evaluate(() => document.getElementById('id-primary').click());
128 await p.waitForSelector('#identity-modal', { state: 'hidden', timeout: 15000 }).catch(() => {});
129 }
130 await p.waitForTimeout(2000);
131}
132
133await p.waitForTimeout(800);
134
135check(await p.evaluate(() => !!window.DaimondSearch),
136 'the browser half of the search tool is loaded');
137
138// ── The stub gateway ────────────────────────────────────────────────
139//
140// Installed over `window.fetch` AFTER the app has wrapped it, so this is the
141// outermost layer and sees the request the app actually composed. Everything
142// that is not the search route falls through untouched.
143async function installStub() {
144 await p.evaluate(() => {
145 window.__searchCalls = [];
146 window.__searchReply = null;
147 const real = window.fetch;
148 // The app's OWN fetch, gateway.js's pause guard included, kept aside. The
149 // stub below sits OUTSIDE that guard — deliberately, so the sections about
150 // what `search.js` decides never reach it — which means the section about
151 // the guard itself has to call past the stub to test anything at all.
152 window.__realFetch = real;
153 window.fetch = function (input, init) {
154 let url = '';
155 try { url = (typeof input === 'string') ? input : (input && input.url) || ''; } catch (e) { url = ''; }
156 if (String(url).indexOf('/api/web/search') !== -1) {
157 let body = null;
158 try { body = JSON.parse((init && init.body) || '{}'); } catch (e) { body = null; }
159 window.__searchCalls.push(body);
160 const j = window.__searchReply || {
161 ok: true, engine: body && body.engine, query: body && body.query, results: [],
162 };
163 return Promise.resolve(new Response(JSON.stringify(j),
164 { status: 200, headers: { 'content-type': 'application/json' } }));
165 }
166 return real.apply(this, arguments);
167 };
168 });
169}
170await installStub();
171
172/// Every request the app has tried to send to the search route so far.
173const calls = () => p.evaluate(() => window.__searchCalls.slice());
174
175/// Run a search in the page and report what came back, or the refusal.
176const trySearch = (q, opts) => p.evaluate(async ({ q, opts }) => {
177 try {
178 const r = await window.DaimondSearch.search(q, opts || {});
179 return { ok: true, value: r };
180 } catch (e) {
181 return { ok: false, message: (e && e.message) || String(e), node: (e && e.pauseNode) || '' };
182 }
183}, { q, opts });
184
185// ── 1. A key is sealed, and is nowhere in the clear ─────────────────
186{
187 await p.evaluate(async (k) => {
188 window.DaimondSearch.setEngine('brave');
189 await window.DaimondSearch.setKey('brave', k);
190 }, SECRET);
191 await p.waitForTimeout(300);
192
193 const held = await p.evaluate((k) => {
194 // EVERY entry, not the one we expect: accounts.js namespaces `daimond-*`,
195 // so looking only where this file thinks the store lives would agree with
196 // itself about a store that had moved.
197 const dump = [];
198 for (let i = 0; i < localStorage.length; i++) {
199 const name = localStorage.key(i);
200 dump.push([name, localStorage.getItem(name) || '']);
201 }
202 let rec = null;
203 for (const [, v] of dump) {
204 try {
205 const j = JSON.parse(v);
206 if (j && j.v === 1 && j.keys && j.keys.brave) { rec = j.keys.brave; break; }
207 } catch (e) { /* not the store */ }
208 }
209 return {
210 leaked: dump.filter(([, v]) => v.indexOf(k) !== -1).map(([n]) => n),
211 rec,
212 inMemory: window.DaimondSearch.key('brave'),
213 has: window.DaimondSearch.hasKey('brave'),
214 };
215 }, SECRET);
216
217 check(held.leaked.length === 0,
218 'the key set for an engine is nowhere in storage in the clear',
219 held.leaked.length ? `found in ${JSON.stringify(held.leaked)}` : 'scanned every entry');
220 check(!!held.rec && !!held.rec.keyEnc,
221 'it is in the store SEALED, so a reload still has it', JSON.stringify(held.rec && Object.keys(held.rec || {})));
222 check(!!held.rec && !held.rec.key,
223 'and the plaintext field of that record was never written',
224 JSON.stringify(held.rec && held.rec.key));
225 check(held.inMemory === SECRET && held.has,
226 'while the app itself can read it, in memory, for the request that pays for it');
227
228 // The lock is the whole of forgetting it: what stays behind is the sealed copy.
229 const afterLock = await p.evaluate(() => {
230 window.DaimondSearch.lock();
231 return { plain: window.DaimondSearch.key('brave'), has: window.DaimondSearch.hasKey('brave'),
232 sealed: window.DaimondSearch.isSealed('brave') };
233 });
234 check(afterLock.plain === '' && afterLock.has && afterLock.sealed,
235 'locking forgets the readable copy and keeps the sealed one', JSON.stringify(afterLock));
236 await p.evaluate(() => window.DaimondSearch.unseal());
237 await p.waitForTimeout(300);
238 check(await p.evaluate(() => window.DaimondSearch.key('brave')) === SECRET,
239 'and unsealing brings it back, so a lock is not a loss');
240}
241
242// ── 2. The picker says what the setting needs ───────────────────────
243//
244// Driven through the real `<select>` and its change event, not through
245// `setEngine`: the property is about what a person sees after choosing, and a
246// row painted only by the module would pass with no listener attached at all.
247{
248 await p.evaluate(() => {
249 // The Models view is where the Search section lives. Opened the way the
250 // rail opens it, so the section is on screen rather than merely in the DOM.
251 const row = document.getElementById('astat-model');
252 if (row) row.click();
253 });
254 await p.waitForTimeout(500);
255
256 /// Choose an engine through the control, and read the row back.
257 const choose = (id) => p.evaluate((want) => {
258 const sel = document.getElementById('set-search-engine');
259 if (!sel) return null;
260 sel.value = want;
261 sel.dispatchEvent(new Event('change', { bubbles: true }));
262 const seen = (el) => !!(el && el.getClientRects().length);
263 const keyRow = document.getElementById('search-key-row');
264 const warn = document.getElementById('search-key-warn');
265 return {
266 chosen: window.DaimondSearch.engine(),
267 offered: [...sel.options].map((o) => o.value),
268 keyShown: seen(keyRow),
269 warn: seen(warn) ? (warn.textContent || '').trim() : '',
270 note: (document.getElementById('search-engine-note') || {}).textContent || '',
271 };
272 }, id);
273
274 const onCredits = await choose('credits');
275 check(!!onCredits && onCredits.chosen === 'credits',
276 'the pulldown sets the engine', JSON.stringify(onCredits && onCredits.chosen));
277 check(!!onCredits && !onCredits.keyShown,
278 'choosing Daimond credits HIDES the key field — the gateway holds that key and there '
279 + 'is nothing here to paste', JSON.stringify(onCredits && onCredits.keyShown));
280 check(!!onCredits && onCredits.offered.includes('serper'),
281 'serper is on offer, because a user may bring their own key for it',
282 JSON.stringify(onCredits && onCredits.offered));
283
284 // NEITHER HALF OF THIS RELEASE WAITS FOR THE OTHER. The i18n lane fills eight
285 // locales in parallel with this file, and `data-i18n` would put the raw key on
286 // screen until it did — a panel headed "search.head". Every string here goes
287 // through `tOr`, so the property is that nothing in the section, or on the new
288 // control, reads like a lookup key.
289 const rawKeys = await p.evaluate(() => {
290 const looksLikeAKey = (s) => /^[a-z][a-z0-9]*(\.[a-z0-9_]+)+$/.test((s || '').trim());
291 const found = [];
292 const sec = document.getElementById('search-section');
293 if (sec) {
294 for (const el of sec.querySelectorAll('*')) {
295 if (el.children.length === 0 && looksLikeAKey(el.textContent)) {
296 found.push(el.id || el.tagName.toLowerCase() + ':' + el.textContent.trim());
297 }
298 for (const a of ['title', 'aria-label', 'placeholder']) {
299 if (looksLikeAKey(el.getAttribute(a))) found.push((el.id || el.tagName) + '@' + a);
300 }
301 }
302 for (const o of sec.querySelectorAll('option')) {
303 if (looksLikeAKey(o.textContent)) found.push('option:' + o.value);
304 }
305 }
306 for (const b of document.querySelectorAll('.pptw[data-pause-node="root/web"] *')) {
307 if (looksLikeAKey(b.getAttribute('aria-label'))) found.push('pptw@' + b.className);
308 }
309 return found;
310 });
311 check(rawKeys.length === 0,
312 'nothing in the section or on the new control shows a raw i18n key, so this half '
313 + 'and the locales can land in either order', JSON.stringify(rawKeys));
314
315 // An engine with no key: the row says what to do, and a search does NOT go out
316 // and come back broken. Exa is untouched by section 1, so it genuinely has none.
317 const onExa = await choose('exa');
318 check(!!onExa && onExa.keyShown, 'choosing an engine of your own shows the key field');
319 check(!!onExa && /\S/.test(onExa.warn),
320 'and one with no key shows a line saying to add one', JSON.stringify(onExa && onExa.warn));
321
322 // THE ALLOWANCE LINE. §9 makes this the only place a free tier may be stated,
323 // which means it has to be per-engine and true. The rule under test is not
324 // "does it say 1,000" — a figure hard-coded in a test is the same staleness
325 // one file to the left — but that the line agrees with the REGISTRY, and that
326 // an engine with no figure we can cite says nothing about being free.
327 {
328 // The line is TWO claims stitched together: a general one about vendors
329 // ("most give a free allowance"), identical whichever engine is chosen,
330 // and a per-engine tail that is the only place a figure may be stated.
331 // Only the tail is judged here, and it is found by removing the general
332 // sentence THE APP ITSELF PAINTED, read back from the catalogue. A copy
333 // of that sentence written out in this file is the same staleness one
334 // directory to the left: an editor's comma turns this check red and it
335 // then reports a product fault where there is none. It also has to be
336 // the general sentence and not merely something before the tail, so the
337 // prefix is asserted rather than assumed — a restructure that folds a
338 // per-engine claim into the shared opening goes red here instead of
339 // slipping past the rule underneath.
340 const general = await p.evaluate(() =>
341 (window.DaimondI18n && window.DaimondI18n.t('search.engine_note')) || '');
342 const seen = [];
343 for (const id of await p.evaluate(() => Object.keys(window.DaimondSearch.KNOWN))) {
344 seen.push(await p.evaluate((want) => {
345 const sel = document.getElementById('set-search-engine');
346 sel.value = want;
347 sel.dispatchEvent(new Event('change', { bubbles: true }));
348 const el = document.getElementById('search-engine-note');
349 return {
350 id: want,
351 free: (window.DaimondSearch.KNOWN[want] || {}).free || 0,
352 note: ((el && el.textContent) || '').trim(),
353 };
354 }, id));
355 }
356 // A figure appears exactly where the registry has one.
357 const wrong = seen.filter((r) => /\d/.test(r.note) !== (r.free > 0));
358 check(wrong.length === 0,
359 'the allowance line appears for exactly the engines the registry has a figure for',
360 JSON.stringify(wrong.map((r) => ({ id: r.id, free: r.free, note: r.note }))));
361 // And it is THAT figure, not one written into the sentence.
362 const withFigure = seen.filter((r) => r.free > 0);
363 const mismatched = withFigure.filter((r) =>
364 r.note.replace(/[^\d]/g, '').indexOf(String(r.free)) === -1);
365 check(withFigure.length > 0 && mismatched.length === 0,
366 'and the number on screen is the registry\'s, so one edit moves it',
367 JSON.stringify(mismatched.map((r) => ({ id: r.id, free: r.free, note: r.note }))));
368 // The general sentence really is the shared opening every note begins with.
369 const strays = seen.filter((r) => !/\S/.test(general) || r.note.indexOf(general) !== 0);
370 check(strays.length === 0,
371 'the sentence about vendors in general opens every engine note, so what follows '
372 + 'it is that engine\'s own claim',
373 JSON.stringify({ general, strays: strays.map((r) => ({ id: r.id, note: r.note })) }));
374 // Nothing says "free" about an engine whose allowance nobody wrote down.
375 const freeWord = seen.filter((r) => r.free === 0 && r.id !== 'credits'
376 && claimsFree(r.note, general));
377 check(freeWord.length === 0,
378 'and no engine without a figure is called free on its own account',
379 JSON.stringify(freeWord.map((r) => ({ id: r.id, tail: engineTail(r.note, general) }))));
380 }
381 await choose('exa');
382
383 const before = (await calls()).length;
384 const refused = await trySearch('anything at all');
385 const after = (await calls()).length;
386 check(!refused.ok, 'searching on an engine with no key is refused', JSON.stringify(refused.message));
387 check(after === before,
388 'and refused HERE — nothing reached the gateway to fail there',
389 `${after - before} request(s) went out`);
390 check(!refused.ok && refused.message === onExa.warn,
391 'the refusal and the line on the row are the SAME sentence, so the fix is where the '
392 + 'complaint is', `${JSON.stringify(refused.message)} vs ${JSON.stringify(onExa.warn)}`);
393
394 // And with a key it goes, naming the engine and carrying the key.
395 await p.evaluate(async (k) => { await window.DaimondSearch.setKey('exa', k + '-exa'); }, SECRET);
396 await p.waitForTimeout(300);
397 const ran = await trySearch('kestrel', { kind: 'web', limit: 3 });
398 const sent = (await calls()).slice(-1)[0];
399 check(ran.ok, 'with a key, the search runs', JSON.stringify(ran.message || ''));
400 check(!!sent && sent.engine === 'exa' && sent.key === SECRET + '-exa',
401 'the request names the chosen engine and carries that engine\'s key',
402 JSON.stringify(sent && { engine: sent.engine, key: !!sent.key }));
403 check(!!sent && sent.query === 'kestrel',
404 'and the query is what was asked for, not a URL built around it', JSON.stringify(sent && sent.query));
405}
406
407// ── 3. serper may never be bought with credits ──────────────────────
408//
409// §3: it resells Google, so its business is an arbitrage that can end without
410// notice. A user taking that risk with their own key is their choice; Oxedyne
411// billing for it is Oxedyne's risk.
412{
413 const set = (id) => p.evaluate((want) => {
414 const sel = document.getElementById('set-search-engine');
415 sel.value = want;
416 sel.dispatchEvent(new Event('change', { bubbles: true }));
417 const warn = document.getElementById('search-key-warn');
418 return {
419 chosen: window.DaimondSearch.engine(),
420 byok: window.DaimondSearch.byokOnly(want),
421 warn: (warn && warn.getClientRects().length) ? (warn.textContent || '').trim() : '',
422 };
423 }, id);
424
425 const onSerper = await set('serper');
426 check(onSerper.chosen === 'serper' && onSerper.byok,
427 'serper is chooseable, and the module knows it is own-key-only');
428
429 const before = (await calls()).length;
430 const r = await trySearch('who resells this');
431 const after = (await calls()).length;
432 check(!r.ok, 'serper with no key is refused', JSON.stringify(r.message));
433 check(after === before,
434 'AND NOTHING WAS SENT — no request went out that the balance could have paid for',
435 `${after - before} request(s) went out`);
436 // The two refusals differ in what they OFFER, and that is the whole point:
437 // an ordinary engine can be swapped for credits and this one cannot.
438 const creditsWord = await p.evaluate(() => window.DaimondSearch.engineName('credits'));
439 check(!r.ok && r.message.indexOf(creditsWord) === -1,
440 'and the refusal does NOT offer to pay for it with credits, which every other '
441 + 'engine\'s refusal does', JSON.stringify(r.message));
442 check(!r.ok && r.message === onSerper.warn,
443 'the row says the same thing where the choice was made', JSON.stringify(onSerper.warn));
444
445 // With a key it is an ordinary BYOK engine — the rule is about who PAYS, not
446 // about whether the engine may be used at all.
447 await p.evaluate(async (k) => { await window.DaimondSearch.setKey('serper', k + '-serper'); }, SECRET);
448 await p.waitForTimeout(300);
449 await trySearch('with my own key');
450 const sent = (await calls()).slice(-1)[0];
451 check(!!sent && sent.engine === 'serper' && !!sent.key,
452 'with a key of their own it runs, as their own key', JSON.stringify(sent && sent.engine));
453
454 // THE GENERAL PROPERTY, over every request this file has caused: a request
455 // naming an own-key engine always carries a key, and one naming `credits`
456 // never does. That is what "serper cannot reach the credits tier" means at
457 // the wire, and it is checked over the whole log rather than the last row.
458 const log = await calls();
459 const byok = await p.evaluate(() => Object.keys(window.DaimondSearch.KNOWN)
460 .filter((id) => window.DaimondSearch.byokOnly(id)));
461 const paidByCredits = log.filter((c) => c && (!c.engine || c.engine === 'credits'));
462 const bought = paidByCredits.filter((c) => byok.includes(c.engine));
463 check(bought.length === 0,
464 'no request in this whole run asked the balance to pay for an own-key-only engine',
465 JSON.stringify(bought));
466 const keyless = log.filter((c) => c && c.engine && c.engine !== 'credits' && !c.key);
467 check(log.length > 0 && keyless.length === 0,
468 'and every request naming an engine of the user\'s carried that user\'s key',
469 JSON.stringify(keyless));
470 check(paidByCredits.every((c) => !c.key),
471 'while a credits request carries no key at all — the gateway holds that one',
472 JSON.stringify(paidByCredits.filter((c) => c.key)));
473}
474
475// ── 4. `root/web` has a control, and it moves that leaf alone ───────
476//
477// The section this release exists for. Every judgement is a SET comparison, so
478// "and only root/web" cannot be satisfied by a count that happens to match.
479{
480 // The Web panel forward, so the control is on screen and not merely in the
481 // document — a control nobody can see is the fault being fixed.
482 await p.evaluate(() => { try { DaimondPanels.show('web'); DaimondPanels.reflow(); } catch (e) { /* no panels */ } });
483 await p.waitForTimeout(600);
484
485 const placed = await p.evaluate(() => {
486 const g = document.querySelector('#panel-web .chead .pptw[data-pause-node="root/web"]');
487 if (!g) return null;
488 const head = g.closest('.chead');
489 const panel = document.getElementById('panel-web');
490 const gr = g.getBoundingClientRect(), pr = panel.getBoundingClientRect();
491 const verb = (act) => {
492 const b = g.querySelector('.pptw-' + act);
493 return b ? { tag: b.tagName.toLowerCase(), label: b.getAttribute('aria-label') || '',
494 disabled: !!b.disabled } : null;
495 };
496 return {
497 inHeader: !!head,
498 visible: g.getClientRects().length > 0,
499 // Inside the panel it belongs to, at this width. The Web panel's header
500 // has overflowed before — the closer once sat entirely outside the panel
501 // it closes — so a new control in that row is measured, not assumed.
502 inside: gr.left >= pr.left - 1 && gr.right <= pr.right + 1,
503 order: [...g.children].map((e) => e.dataset.act || (e.classList.contains('pptw-lamp') ? 'lamp' : '?')),
504 play: verb('play'),
505 pause: verb('pause'),
506 lampKids: (g.querySelector('.pptw-lamp') || { childElementCount: -1 }).childElementCount,
507 };
508 });
509
510 check(!!placed, 'THE WEB PANEL HAS A PAUSE CONTROL, governing `root/web`');
511 if (placed) {
512 check(placed.inHeader && placed.visible, 'it is in the panel header, on screen',
513 JSON.stringify({ inHeader: placed.inHeader, visible: placed.visible }));
514 check(placed.inside, 'and inside the panel it belongs to, not pushed out of its own header');
515 check(JSON.stringify(placed.order) === '["play","pause","lamp"]',
516 'drawn as the one widget the rest of the app draws — play, pause, then the light',
517 JSON.stringify(placed.order));
518 check(placed.lampKids === 0, 'with nothing inside the light but its colour');
519 check(!!placed.play && placed.play.tag === 'button' && /[\p{L}\p{N}]/u.test(placed.play.label),
520 'the play verb is a real button with a name that can be read out',
521 JSON.stringify(placed.play));
522 check(!!placed.pause && /[\p{L}\p{N}]/u.test(placed.pause.label),
523 'and so is pause', JSON.stringify(placed.pause));
524 }
525
526 /// Press a verb on the Web control the way a finger does, and report the
527 /// paused set either side of it.
528 const press = (act) => p.evaluate((a) => {
529 const before = DaimondPause.pausedIds().slice().sort();
530 const b = document.querySelector('#panel-web .pptw[data-pause-node="root/web"] .pptw-' + a);
531 if (!b) return { before, after: before, pressed: false, wasDisabled: null };
532 const wasDisabled = !!b.disabled;
533 b.click();
534 return { before, after: DaimondPause.pausedIds().slice().sort(), pressed: true, wasDisabled };
535 }, act);
536
537 // (a) From nothing paused: pause here pauses here, and nothing else.
538 await p.evaluate(() => DaimondPause.set('root', true));
539 await p.waitForTimeout(200);
540 const one = await press('pause');
541 check(one.pressed && sameSet(one.before, []),
542 'nothing is paused at the start of this check', JSON.stringify(one.before));
543 check(sameSet(one.after, ['root/web']),
544 'pressing pause on it pauses `root/web`, and pauses nothing else',
545 JSON.stringify(one.after));
546
547 const back = await press('play');
548 check(sameSet(back.after, []), 'and play puts it back', JSON.stringify(back.after));
549
550 // (b) THE CASE THAT WENT WRONG. Everything paused — which is where the user
551 // was — and play on this one leaf must release this one leaf. Nothing else
552 // may move: `root/diamonds/<optimiser>/…` is paused ON PURPOSE, and a control
553 // that resumed it while letting the web out would be the bug in the other
554 // direction.
555 await p.evaluate(() => DaimondPause.set('root', false));
556 await p.waitForTimeout(200);
557 const all = await p.evaluate(() => DaimondPause.pausedIds().slice().sort());
558 check(all.length > 1 && all.includes('root/web'),
559 'with Everything paused, the tree holds more than the web leaf — so "only" means something',
560 JSON.stringify(all));
561
562 const freed = await press('play');
563 check(!freed.wasDisabled, 'the play verb is live while the leaf is held');
564 check(!freed.after.includes('root/web'),
565 'PLAY ON THE WEB CONTROL RESUMES `root/web` FROM A FULLY PAUSED TREE — which is the '
566 + 'thing that had no button this morning');
567 check(sameSet(minus(all, ['root/web']), freed.after),
568 'AND TOUCHES NO OTHER LEAF — everything else the user had paused is still paused',
569 `moved: ${JSON.stringify(minus(all, freed.after).filter((x) => x !== 'root/web'))}, `
570 + `gained: ${JSON.stringify(minus(freed.after, all))}`);
571
572 // And the reverse, from the same state: pausing it again restores exactly
573 // what was there, so the control is symmetrical rather than merely a release.
574 const held = await press('pause');
575 check(sameSet(held.after, all),
576 'pausing it again leaves the tree exactly as it was', JSON.stringify(minus(held.after, all)));
577
578 // (c) THE REFUSAL NAMES A CONTROL THAT EXISTS. This is the whole complaint,
579 // stated as a property rather than as a string: whatever the sentence says,
580 // the node it blames must have a live play verb somewhere a person can reach.
581 //
582 // Checked this way ON PURPOSE. The English of `pause.refused.web` lives in
583 // `www/i18n/*.js`, which is another lane's file, so a check that matched its
584 // wording would be measuring that lane's timing. What must hold either side
585 // of it is that the node named has a control.
586 const refusal = await trySearch('while it is held');
587 check(!refusal.ok && !!refusal.node,
588 'a search while the leaf is held is refused, and the refusal names the node',
589 JSON.stringify(refusal));
590 const reachable = await p.evaluate((node) => {
591 const b = document.querySelector(`.pptw[data-pause-node="${node}"] .pptw-play`);
592 if (!b) return { found: false };
593 return { found: true, disabled: !!b.disabled, visible: b.getClientRects().length > 0 };
594 }, refusal.node || 'root/web');
595 check(reachable.found && reachable.visible && !reachable.disabled,
596 'and there is a live play verb on screen for exactly that node — the refusal points at '
597 + 'something', JSON.stringify(reachable));
598
599 await p.evaluate(() => DaimondPause.set('root', true));
600 await p.waitForTimeout(200);
601}
602
603// ── 5. The query is shown before it leaves ──────────────────────────
604//
605// §7 says a search goes out through `egress_check_detail` WITH THE QUERY as the
606// detail, "exactly as `web_type` shows the text it is about to send". It was not
607// happening. The wasm names `/api/web/search` — it has to, because it cannot
608// know which engine the setting will reach — and that URL is same-origin, so the
609// gate's shortcut for Daimond's own pages waved every search straight through
610// and the user was asked nothing at all.
611//
612// The gate is called BY NAME here, the same way the wasm calls it, so what is
613// under test is the door rather than the tool that knocks on it.
614{
615 const Q = 'peregrine stoop speed, measured rather than claimed';
616
617 /// Put a search to the gate and leave the verdict outstanding.
618 const ask = (q) => p.evaluate((query) => {
619 window.__settled = 'PENDING';
620 window.__daimondEgressAllowed(JSON.stringify({
621 tool: 'web_search', url: '/api/web/search', detail: query,
622 })).then((v) => { window.__settled = v; });
623 }, q);
624 const dialogNow = () => p.evaluate(() => {
625 const d = document.querySelector('.modal.dlg .dlg-card');
626 return {
627 shown: !!d,
628 settled: window.__settled,
629 title: d ? ((d.querySelector('h2') || {}).textContent || '') : '',
630 body: d ? ((d.querySelector('.dlg-msg') || {}).textContent || '') : '',
631 ok: d ? ((d.querySelector('.dlg-ok') || {}).textContent || '') : '',
632 hasNo: !!(d && d.querySelector('.dlg-cancel')),
633 };
634 });
635
636 await ask(Q);
637 await p.waitForTimeout(400);
638 const d1 = await dialogNow();
639 check(d1.shown, 'A SEARCH ASKS BEFORE IT GOES — the gate is not waved through as same-origin');
640 check(d1.settled === 'PENDING',
641 'and it is still waiting on the answer rather than already allowed',
642 JSON.stringify(d1.settled));
643 check(d1.body.indexOf(Q) !== -1,
644 'THE DIALOG SHOWS THE QUERY — the thing that actually leaves the device',
645 JSON.stringify(d1.body.slice(0, 120)));
646 check((d1.body + d1.title).indexOf('/api/web/search') === -1
647 && !/%[0-9A-Fa-f]{2}/.test(d1.body + d1.title),
648 'and NOT the gateway path the wasm had to name, nor a percent-encoded URL — which is '
649 + 'what made this morning\'s prompt unreadable');
650 const engNow = await p.evaluate(() =>
651 window.DaimondSearch.engineName(window.DaimondSearch.engine()));
652 check(d1.body.indexOf(engNow) !== -1,
653 'it names the engine the SETTING will reach, not one the model picked',
654 `${JSON.stringify(engNow)} in ${JSON.stringify(d1.body.slice(-160))}`);
655 check(d1.hasNo && /\S/.test(d1.ok),
656 'and it offers both answers, each with words on it', JSON.stringify(d1.ok));
657
658 // PRESSED IF IT IS THERE. The dialog is the thing under test, so it is absent
659 // exactly when this section is failing -- and a bare `.click()` on the missing
660 // card threw, which took the whole run down at the point it had most to say.
661 // `--unbuilt` could not finish for that reason and reported nothing.
662 await p.evaluate(() => { const b = document.querySelector('.modal.dlg .dlg-cancel'); if (b) b.click(); });
663 await p.waitForTimeout(300);
664 check(await p.evaluate(() => window.__settled) === 'deny',
665 'declining refuses the search');
666
667 // NEVER REMEMBERED. `web_type` is not remembered because the text is the
668 // payload; a query is the same kind of thing, and a yes to one search is not
669 // a yes to the next. Allowed once, then asked again.
670 await ask(Q);
671 await p.waitForTimeout(400);
672 check((await dialogNow()).shown, 'a second search asks again rather than riding the first yes');
673 await p.evaluate(() => { const b = document.querySelector('.modal.dlg .dlg-ok'); if (b) b.click(); });
674 await p.waitForTimeout(300);
675 check(await p.evaluate(() => window.__settled) === 'allow', 'and allowing lets it through');
676
677 await ask('a third, quite different question');
678 await p.waitForTimeout(400);
679 const d3 = await dialogNow();
680 check(d3.shown, 'and the one after THAT asks too — consent is per search, not per host');
681 check(d3.body.indexOf('a third, quite different question') !== -1,
682 'showing the new query and not the one already answered for');
683 await p.evaluate(() => { const b = document.querySelector('.modal.dlg .dlg-cancel'); if (b) b.click(); });
684 await p.waitForTimeout(300);
685
686 // An empty query is nothing to authorise and nothing to show, so it is refused
687 // without a dialog rather than putting an empty box in front of somebody.
688 await ask(' ');
689 await p.waitForTimeout(400);
690 const d4 = await dialogNow();
691 check(!d4.shown && d4.settled === 'deny',
692 'an empty query is refused outright, with no empty dialog to answer',
693 JSON.stringify(d4.settled));
694}
695
696// ── 6. The spend guard knows the search route ───────────────────────
697//
698// `search.js` checks the pause itself, which is the right instinct and is what
699// section 4 exercised. This is the OTHER hold: `gateway.js` wraps `window.fetch`
700// so a caller that does not ask is refused anyway. `/api/web/search` matched no
701// arm of `spendRefusal` at all, so it fell out of the bottom governed by
702// nothing — not the Web leaf, not a Diamond's node, and not the global control.
703// Two independent holds is correct: `search.js` is the only caller today, and a
704// second one arriving later would otherwise be ungoverned with nothing red.
705{
706 /// Past the stub, through the app's own guarded fetch.
707 const call = () => p.evaluate(async () => {
708 try {
709 const r = await window.__realFetch('/api/web/search', {
710 method: 'POST',
711 headers: { 'content-type': 'application/json' },
712 body: JSON.stringify({ query: 'while it is held' }),
713 });
714 let j = null;
715 try { j = await r.json(); } catch (e) { j = null; }
716 return { status: r.status, j };
717 } catch (e) {
718 // No gateway in this world, so an unrefused call fails at the network.
719 // That is not a 423, which is the whole distinction being drawn.
720 return { status: 0, j: null, err: String(e && e.message || e) };
721 }
722 });
723
724 await p.evaluate(() => { DaimondPause.set('root', true); DaimondPause.set('root/web', false); });
725 await p.waitForTimeout(200);
726 const onLeaf = await call();
727 check(onLeaf.status === 423 && !!onLeaf.j && onLeaf.j.paused === true,
728 'a search is refused by the spend guard while `root/web` is held',
729 JSON.stringify(onLeaf));
730 check(!!onLeaf.j && onLeaf.j.node === 'root/web',
731 'and the refusal names the leaf, so a caller can point at its control',
732 JSON.stringify(onLeaf.j && onLeaf.j.node));
733
734 // THE CASE THE USER WAS ACTUALLY IN. They paused Everything to stop outbound
735 // requests. A search that ignored the root would have gone on spending.
736 await p.evaluate(() => { DaimondPause.set('root', true); DaimondPause.set('root', false); });
737 await p.waitForTimeout(200);
738 const onRoot = await call();
739 check(onRoot.status === 423 && !!onRoot.j && onRoot.j.paused === true,
740 'AND REFUSED WITH EVERYTHING PAUSED — which is what the user did this morning',
741 JSON.stringify(onRoot));
742
743 await p.evaluate(() => DaimondPause.set('root', true));
744 await p.waitForTimeout(200);
745 const free = await call();
746 check(free.status !== 423,
747 'and with nothing held the guard lets go — it holds a search, it does not block one',
748 JSON.stringify({ status: free.status, err: free.err }));
749}
750
751// ── Self-tests: each property shown going red ───────────────────────
752out.push('');
753out.push('--- self-test: breaking each property in the live page');
754
755// (a) A control that kept its markup and lost its listener still LOOKS operable.
756{
757 const r = await p.evaluate(() => {
758 DaimondPause.set('root', false);
759 const g = document.querySelector('#panel-web .pptw[data-pause-node="root/web"]');
760 if (!g) return null;
761 const twin = g.cloneNode(true);
762 g.parentNode.replaceChild(twin, g);
763 const before = DaimondPause.pausedIds().slice().sort();
764 twin.querySelector('.pptw-play').click();
765 const after = DaimondPause.pausedIds().slice().sort();
766 twin.parentNode.replaceChild(g, twin);
767 return { before, after };
768 });
769 red(!!r && sameSet(r.before, r.after),
770 'a web control that lost its listener releases nothing, and the set comparison sees it');
771 await p.evaluate(() => window.dispatchEvent(new CustomEvent('daimond:pause')));
772}
773
774// (b) A control wired to the ROOT instead of its leaf — which is exactly what
775// "just resume everything" was, drawn as a button. The set difference is what
776// catches it; a check that only asked "is the web running again" would pass.
777{
778 const r = await p.evaluate(() => {
779 DaimondPause.set('root', false);
780 const all = DaimondPause.pausedIds().slice().sort();
781 DaimondPause.set('root', true); // a play verb wired to the root
782 return { all, after: DaimondPause.pausedIds().slice().sort() };
783 });
784 red(!!r && r.all.length > 1 && r.after.length === 0,
785 'a play verb wired to the root resumes everything, and "touches no other leaf" fails');
786 await p.evaluate(() => DaimondPause.set('root', true));
787}
788
789// (c) The key written in the clear. Planted directly in storage, so the scan is
790// shown finding a secret it would otherwise be asserting the absence of.
791{
792 const r = await p.evaluate((k) => {
793 let name = null;
794 for (let i = 0; i < localStorage.length; i++) {
795 const n = localStorage.key(i);
796 try {
797 const j = JSON.parse(localStorage.getItem(n) || 'null');
798 if (j && j.v === 1 && j.keys) { name = n; break; }
799 } catch (e) { /* not the store */ }
800 }
801 if (!name) return null;
802 const kept = localStorage.getItem(name);
803 const j = JSON.parse(kept);
804 j.keys.brave = { key: k, keyEnc: j.keys.brave ? j.keys.brave.keyEnc : '' };
805 localStorage.setItem(name, JSON.stringify(j));
806 let seen = 0;
807 for (let i = 0; i < localStorage.length; i++) {
808 if ((localStorage.getItem(localStorage.key(i)) || '').indexOf(k) !== -1) seen++;
809 }
810 localStorage.setItem(name, kept); // put it back
811 return { seen };
812 }, SECRET);
813 red(!!r && r.seen > 0, 'a key written in the clear IS found by the storage scan');
814}
815
816// (d) serper on the balance. The general property is re-run over a log with one
817// forged row in it, so the check is shown catching the shape it exists for.
818{
819 const forged = (await calls()).concat([{ engine: 'serper', query: 'x' }]);
820 const byok = await p.evaluate(() => Object.keys(window.DaimondSearch.KNOWN)
821 .filter((id) => window.DaimondSearch.byokOnly(id)));
822 const keyless = forged.filter((c) => c && c.engine && c.engine !== 'credits' && !c.key);
823 red(byok.includes('serper') && keyless.length > 0,
824 'a serper request with no key of its own is caught by the whole-log rule');
825}
826
827// (e) The egress gate answering without asking — the state it was in. Shown by
828// putting a same-origin request through the door WITHOUT the `web_search` tool
829// name, which is the arm that was missing: it takes the shortcut and allows.
830{
831 const v = await p.evaluate(() => window.__daimondEgressAllowed(JSON.stringify({
832 url: '/api/web/search?q=a+question+nobody+saw',
833 })));
834 red(v === 'allow',
835 'a search wearing no tool name is still waved through as same-origin — which is '
836 + 'precisely why the arm has to be keyed on the tool and sit above that shortcut');
837}
838
839// (f) The spend guard with the route removed. The path is edited to one it does
840// not know, so the guard is shown answering nothing for a route it has not been
841// told about — the shape of the gap this release closed.
842{
843 const r = await p.evaluate(async () => {
844 DaimondPause.set('root', false); // everything held
845 try {
846 const res = await window.__realFetch('/api/web/searchXX', {
847 method: 'POST', headers: { 'content-type': 'application/json' },
848 body: JSON.stringify({ query: 'x' }),
849 });
850 return { status: res.status };
851 } catch (e) { return { status: 0 }; }
852 });
853 red(r.status !== 423,
854 'an unknown web route is refused by nothing even with Everything paused, so the '
855 + 'guard really is matching on the path and not on a prefix');
856 await p.evaluate(() => DaimondPause.set('root', true));
857}
858
859// (g) A free tier claimed for an engine nobody wrote a figure for — the shape
860// §9 forbids, and the one this rule exists to catch. The sentence is planted on
861// the live note for an engine whose registry entry has no figure, then the same
862// `claimsFree` the check above runs is run over it. The general sentence stays
863// where it is, so this also shows that the rule is reading the per-engine tail
864// and not merely finding the word "free" somewhere in the paragraph.
865{
866 const r = await p.evaluate(() => {
867 const sel = document.getElementById('set-search-engine');
868 const el = document.getElementById('search-engine-note');
869 if (!sel || !el) return null;
870 const bare = Object.keys(window.DaimondSearch.KNOWN).find((id) =>
871 id !== window.DaimondSearch.CREDITS && !(window.DaimondSearch.KNOWN[id] || {}).free);
872 if (!bare) return null;
873 sel.value = bare;
874 sel.dispatchEvent(new Event('change', { bubbles: true }));
875 const general = (window.DaimondI18n && window.DaimondI18n.t('search.engine_note')) || '';
876 const honest = (el.textContent || '').trim();
877 el.appendChild(document.createTextNode(' It is free to use.'));
878 const forged = (el.textContent || '').trim();
879 sel.dispatchEvent(new Event('change', { bubbles: true })); // painted back
880 return { id: bare, general, honest, forged };
881 });
882 red(!!r && !claimsFree(r.honest, r.general) && claimsFree(r.forged, r.general),
883 'an engine with no figure that is told it is free IS caught, while the general '
884 + 'sentence about vendors in the same paragraph is not mistaken for one');
885}
886
887// (h) THE PAUSE VERB, AND WHAT KEEPS IT ALIVE. §4 above presses pause on the Web
888// control and watches `root/web` go into the paused set. That only works because
889// the leaf is marked `stoppable` in `pauseTree`, and nothing but a comment said
890// so until this ran: on 2026-08-28 the light started counting ARMED leaves, and
891// `root/web` arms nothing — a page is fetched because a turn asked for one — so
892// its control read `idle`, which greys the pause verb. The leaf could be released
893// from its own control and never held from it, for a day, with the light saying
894// nothing was wrong because the light was not what broke.
895//
896// The mark is taken off HERE, in the live page, by hiding it from the one reader
897// — `paintPause` asks `DaimondPause._core.findNode` — and the verb must die with
898// it. Then it is put back, so this proves the mark and not the patch.
899{
900 const r = await p.evaluate(async () => {
901 try { DaimondPanels.show('web'); DaimondPanels.reflow(); } catch (e) { /* no panels */ }
902 const core = DaimondPause._core, real = core.findNode;
903 const verb = () => {
904 const b = document.querySelector('#panel-web .pptw[data-pause-node="root/web"] .pptw-pause');
905 return b ? { there: true, disabled: !!b.disabled, press: () => b.click() } : { there: false };
906 };
907 // A repaint without a change of state: set() announces only when the set
908 // really moves, so the leaf is held and released to bring the paint round.
909 const repaint = () => { DaimondPause.set('root/web', false); DaimondPause.set('root/web', true); };
910 DaimondPause.set('root', true);
911 const live = verb().disabled;
912 core.findNode = function (tree, id) {
913 const n = real(tree, id);
914 if (!n || id !== 'root/web') return n;
915 const copy = {}; for (const k in n) if (k !== 'stoppable') copy[k] = n[k];
916 return copy;
917 };
918 repaint();
919 const dead = verb();
920 if (dead.press) dead.press(); // absent under --unbuilt, where nothing is mounted
921 const after = DaimondPause.pausedIds().slice();
922 core.findNode = real;
923 repaint();
924 const back = verb().disabled;
925 return { there: dead.there, live, dead: dead.disabled, after, back };
926 });
927 red(!!r && r.there && r.live === false && r.dead === true && r.after.length === 0 && r.back === false,
928 'a `root/web` that is no longer marked stoppable loses its pause verb, and pressing '
929 + 'it holds nothing — which is what §4 above is standing on');
930}
931
932await s.close();
933
934console.log(out.join('\n'));
935const total = out.filter((l) => /^(PASS|FAIL)/.test(l)).length;
936if (UNBUILT) {
937 console.log(`\nUNBUILT RUN: ${bad} of ${total} checks failed. `
938 + (bad > 0 ? 'Good — the checks see what is missing.'
939 : 'BAD — a check that cannot fail is not evidence.'));
940 process.exit(bad > 0 ? 0 : 1);
941}
942console.log(bad === 0 ? `\nALL ${total} CHECKS PASSED` : `\n${bad} of ${total} FAILED`);
943process.exit(bad === 0 ? 0 : 1);