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 | |
| 41 | import fs from 'node:fs'; |
| 42 | import { open, scratch } from './harness.mjs'; |
| 43 | |
| 44 | const UNBUILT = process.argv.includes('--unbuilt'); |
| 45 | |
| 46 | const out = []; |
| 47 | let bad = 0; |
| 48 | const check = (ok, what, detail) => { |
| 49 | out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`); |
| 50 | if (!ok) bad++; |
| 51 | return ok; |
| 52 | }; |
| 53 | const 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. |
| 56 | const 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`. |
| 61 | const 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. |
| 65 | const 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. |
| 68 | const 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. |
| 71 | const SECRET = 'searchkey-' + process.pid + '-do-not-store-in-the-clear'; |
| 72 | |
| 73 | const profile = scratch('pw', 'searchapp-' + process.pid); |
| 74 | const s = await open({ name: 'searchapp' + process.pid, profile }); |
| 75 | const closeBrowser = s.close; |
| 76 | s.close = async () => { |
| 77 | await closeBrowser(); |
| 78 | try { fs.rmSync(profile, { recursive: true, force: true }); } catch (e) { /* gone */ } |
| 79 | }; |
| 80 | const 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. |
| 86 | if (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 | |
| 133 | await p.waitForTimeout(800); |
| 134 | |
| 135 | check(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. |
| 143 | async 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 | } |
| 170 | await installStub(); |
| 171 | |
| 172 | /// Every request the app has tried to send to the search route so far. |
| 173 | const calls = () => p.evaluate(() => window.__searchCalls.slice()); |
| 174 | |
| 175 | /// Run a search in the page and report what came back, or the refusal. |
| 176 | const 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 ─────────────────────── |
| 752 | out.push(''); |
| 753 | out.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 | |
| 932 | await s.close(); |
| 933 | |
| 934 | console.log(out.join('\n')); |
| 935 | const total = out.filter((l) => /^(PASS|FAIL)/.test(l)).length; |
| 936 | if (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 | } |
| 942 | console.log(bad === 0 ? `\nALL ${total} CHECKS PASSED` : `\n${bad} of ${total} FAILED`); |
| 943 | process.exit(bad === 0 ? 0 : 1); |