oxedyne/daimond/dev/verify_netchip.mjs
33.2 KiB, 1 run
created by r2519314175:547, 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_netchip.mjs — the permissions button says what it governs, shows whether |
| 2 | // THIS chat's commands have the network, and lets that be granted and taken back. |
| 3 | // |
| 4 | // THE DEFECT, reported on 2026-08-19 by the owner, about a dialog he had been |
| 5 | // approving for weeks: *"I always click approve. I never even understand the |
| 6 | // message itself."* Three things were wrong and only the first is cosmetic. |
| 7 | // |
| 8 | // 1. THE HOVER NAMED THE CATEGORY. `permmode.chip_help` was "Permission mode: |
| 9 | // what Daimond does without asking" — which is what somebody hovering a |
| 10 | // button marked Guarded can already see. The sentence that answers them was |
| 11 | // written, and sat a click away inside the popover as `guarded_blurb`. |
| 12 | // 2. THE DIALOG SAID "THIS TURN" AND MEANT THIS CHAT. The answer is kept on the |
| 13 | // chat's own engine object (`ensureApp`, daimond.js), which lasts until the |
| 14 | // tab reloads — so a reader who took the word at face value expected to be |
| 15 | // asked again next message and was not. |
| 16 | // 3. AND THERE WAS NO WAY BACK. `set_net_consent` writes once and never |
| 17 | // overwrites, which is right for the tool loop and wrong for a person: an |
| 18 | // answer given in passing could not be changed, and a chat that had lost the |
| 19 | // network showed nothing anywhere to say so. |
| 20 | // |
| 21 | // A FIRST ATTEMPT FIXED 1 AND 3 AND LEFT THE ASKING EXACTLY WHERE IT WAS, which |
| 22 | // the same reporter said in the same words a day later: "I thought we got rid of |
| 23 | // this bullshit!" The answer lived on the chat's engine object, built per chat and |
| 24 | // gone on reload, so it had to be given again in every new chat and after every |
| 25 | // refresh -- and it was only ever given by somebody who had gone into the menu |
| 26 | // looking for it. It was made answerable, not answered. The standing choice is |
| 27 | // what actually removes the interruption, and check 7 is the one that proves it. |
| 28 | // |
| 29 | // NINE PROPERTIES: |
| 30 | // |
| 31 | // 1. THE HOVER NAMES THE RUNG. Asserted as the pair — it carries the rung's own |
| 32 | // word AND is not the old category sentence — because a tooltip that merely |
| 33 | // changed would satisfy either half alone. |
| 34 | // 2. A CLEAN CHAT SAYS SO AND IS NOT MARKED. The section is drawn, it says |
| 35 | // nothing is withheld, and the chip carries no mark. |
| 36 | // 3. A CHAT THAT HAS READ A STRANGER'S WORDS IS MARKED, on the button, where it |
| 37 | // can be seen without opening anything. |
| 38 | // 4. GRANTING IT MOVES THE ENGINE. Read from `net_state()` and not from the |
| 39 | // button that was pressed: a control that relabels itself having changed |
| 40 | // nothing is the failure this is for. |
| 41 | // 5. AND IT CAN BE TAKEN BACK, which is the thing that was impossible. Both |
| 42 | // directions, because a one-way control that happens to start in the other |
| 43 | // state would satisfy check 4 on its own. |
| 44 | // 6. THE SECTION IS NOT A FOURTH RUNG. Still exactly three radios in the ladder, |
| 45 | // with the network under a head of its own -- the rungs are one policy for the |
| 46 | // whole app and this is one answer, and giving it the ladder's shape would say |
| 47 | // the two were the same kind of thing. |
| 48 | // 7. AND A CHAT THAT DID NOT EXIST WHEN YOU ANSWERED IS NOT ASKED. This is the |
| 49 | // property the whole thing is for. Measured at the engine, because |
| 50 | // `NetStep::Ask` is the only branch that raises a dialog: a NEW chat that |
| 51 | // reports `allowed` while marked has had the question answered before it could |
| 52 | // be put. Watching for a dialog instead would also pass on a chat that simply |
| 53 | // never got as far as a command. |
| 54 | // 8. AND THE DIALOG CAN END THE ASKING IN ONE GESTURE -- a TICK the person makes, |
| 55 | // clicked on the card that is actually up. |
| 56 | // 9. WHILE A YES NOBODY TICKED CHANGES NOTHING ELSE. Added 2026-08-28, and it is |
| 57 | // the one that matters most. Until then ANY yes here called |
| 58 | // `setStandingNet('allow')`, which writes the app-wide standing answer and |
| 59 | // pushes `allow` into every engine that exists. A NO said in one chat was |
| 60 | // silently overwritten by a YES said in another about a DIFFERENT command: |
| 61 | // the first chat's next command reached the network, nobody was asked, and the |
| 62 | // permissions button read "Always" -- a setting the user never chose, standing |
| 63 | // through every reload. The reasoning in the code argued that only a yes should |
| 64 | // become standing because refusing once is not refusing for ever, which is true |
| 65 | // and points the wrong way: the direction being made permanent and global was |
| 66 | // the PERMISSIVE one. `src/tools.rs` says of `override_net_consent`, where that |
| 67 | // path ends, that NOTHING IN THE TOOL LOOP MAY CALL IT. |
| 68 | // |
| 69 | // PROVED AGAINST BROKEN CODE FIRST, each break chosen to survive every check but |
| 70 | // the ones under test: |
| 71 | // |
| 72 | // node dev/verify_netchip.mjs --break generic # 1-2: the hover names the category again |
| 73 | // node dev/verify_netchip.mjs --break nomark # 3, 5: a cut chat looks like a clean one |
| 74 | // node dev/verify_netchip.mjs --break stuck # 4-5: the choice is stored and never applied |
| 75 | // node dev/verify_netchip.mjs --break perchat # 7: the answer dies with the chat |
| 76 | // node dev/verify_netchip.mjs --break nosection# 8 checks, crudely |
| 77 | // node dev/verify_netchip.mjs --break stickyyes# 9: every yes becomes standing again |
| 78 | // node dev/verify_netchip.mjs --break notick # 8-9: no control to press at all |
| 79 | // node dev/verify_netchip.mjs --break yesnotsticky # 8: the tick is drawn and does nothing |
| 80 | // node dev/verify_netchip.mjs --break deafnote # 10: a command with no network says nothing |
| 81 | // node dev/verify_netchip.mjs # and then, clean |
| 82 | // |
| 83 | // `perchat` is the SHARP one: it restores the reported defect exactly -- the |
| 84 | // standing answer stored, applied to the engines that exist, and never handed to |
| 85 | // the next one -- and it reddens check 7 and NOTHING ELSE. Every check before it |
| 86 | // passes, because everything before it was working the day the defect was |
| 87 | // reported. That is what makes 7 a test of its own and not a rider on the others. |
| 88 | // |
| 89 | // `stickyyes` is the SHARPEST, and it is sharp in the other direction: it restores |
| 90 | // the consent defect exactly -- every yes made standing, whatever the box says -- |
| 91 | // and reddens check 9 and nothing else. Everything before it passes, because |
| 92 | // everything before it is a property the standing answer is supposed to have and |
| 93 | // still has. A defect that leaves every existing check green is what this file is |
| 94 | // for; the previous one sat here for a week. |
| 95 | // |
| 96 | // `stuck` is its sibling one layer up: the choice recorded and never pushed into |
| 97 | // any engine. A Rust break exists for the write-once rule that sits under both -- |
| 98 | // `override_net_consent` folded back into `set_net_consent` -- which no served file |
| 99 | // can reach; it is held by `test_the_user_can_take_an_answer_back_though_the_ |
| 100 | // dialog_cannot`, run against that exact edit and red on "a no could not be taken |
| 101 | // back". |
| 102 | // |
| 103 | // `nosection` is the crude one: eight checks at once, so it proves nothing about |
| 104 | // any check after the first. It is kept because the section being absent is a real |
| 105 | // way this can break, not because it tests anything sharply. |
| 106 | // |
| 107 | // THE MARK IS SET DIRECTLY, through `DaimondCore.markRead`. It is the same one-way |
| 108 | // flag every real path ends at, so the STATE under test is the real state; which |
| 109 | // reads produce it is a Rust question and is answered there. |
| 110 | // |
| 111 | // AND THE STANDING CHOICE IS ARMED OUTRIGHT at the start. It is `localStorage` and |
| 112 | // the harness reuses its profile, so a run that ended on "always allow" left the |
| 113 | // next one starting there -- which duly failed checks 3 and 4 against an app that |
| 114 | // was working perfectly. A probe that assumes its starting state measures the last |
| 115 | // run. |
| 116 | // |
| 117 | // eval "$(bash dev/world.sh 4 --up)" |
| 118 | // node dev/verify_netchip.mjs |
| 119 | // |
| 120 | // Needs dev/serve.mjs and the mock. No gateway, no wasm rebuild. |
| 121 | import fs from 'node:fs'; |
| 122 | import path from 'node:path'; |
| 123 | import { fileURLToPath } from 'node:url'; |
| 124 | import { open, newChat, scratch, shot } from './harness.mjs'; |
| 125 | |
| 126 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 127 | const WWW = path.join(HERE, '..', 'www'); |
| 128 | |
| 129 | const BREAK = (() => { |
| 130 | const i = process.argv.indexOf('--break'); |
| 131 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 132 | })(); |
| 133 | |
| 134 | // Each break is one real edit to one real file, served in its place. |
| 135 | const BREAKS = { |
| 136 | // The tooltip as it stood: the category, which the word on the button already gives. |
| 137 | generic: { |
| 138 | file: 'js/handmode.js', |
| 139 | find: "\t\t\tchip.title = label(current) + ' — ' + blurb(current);", |
| 140 | with: "\t\t\tchip.title = t('permmode.chip_help');", |
| 141 | }, |
| 142 | // The mark taken off, so a chat with no network looks exactly like one with it. |
| 143 | nomark: { |
| 144 | file: 'js/handmode.js', |
| 145 | find: "\t\t\tchip.classList.toggle('net-cut', cut);", |
| 146 | with: "\t\t\tchip.classList.toggle('net-cut', false);", |
| 147 | }, |
| 148 | // The button that can only ever say yes — `override_net_consent` folded back |
| 149 | // into the write-once rule, restored where a served file can reach it. |
| 150 | // The standing answer stored but never pushed into an engine -- which is what |
| 151 | // "I granted it and it asked me again" actually looks like in code. |
| 152 | stuck: { |
| 153 | file: 'js/handmode.js', |
| 154 | find: "\t\tif (typeof cfg.netApplyAll === 'function') {", |
| 155 | with: "\t\tif (false) {", |
| 156 | }, |
| 157 | // The dialog's yes answering this chat's engine and nothing else -- exactly |
| 158 | // what it did through three reports. The tick is on screen and does nothing. |
| 159 | yesnotsticky: { |
| 160 | file: 'js/daimond.js', |
| 161 | find: "\t\t\tif (netStanding && window.DaimondHandMode && DaimondHandMode.setStandingNet) {", |
| 162 | with: "\t\t\tif (false) {", |
| 163 | }, |
| 164 | // THE SHARP ONE FOR CHECK 9, and the defect it restores is the worst this file |
| 165 | // has held: EVERY yes becomes the app-wide standing answer, whatever the box |
| 166 | // says. That is what the code did until 2026-08-28 -- `setStandingNet('allow')` |
| 167 | // on any yes, which writes `daimond-net-standing` AND pushes `allow` into every |
| 168 | // engine that exists (`netApplyAll`). A no said in one chat was overwritten by a |
| 169 | // yes said in another about a different command, with nothing on screen to say |
| 170 | // so. It reddens 9 and NOTHING ELSE: everything before it is a property the |
| 171 | // standing answer is supposed to have, and it still has them. |
| 172 | stickyyes: { |
| 173 | file: 'js/daimond.js', |
| 174 | find: "\t\t\tnetStanding = !!netAns.standing;", |
| 175 | with: "\t\t\tnetStanding = true;", |
| 176 | }, |
| 177 | // And the other way: no control at all, so "one yes is enough" has no way to be |
| 178 | // said. Reddens 8, and 9's first check with it. |
| 179 | notick: { |
| 180 | file: 'js/daimond.js', |
| 181 | find: "\t\t\t\t\trow.appendChild(box);", |
| 182 | with: "\t\t\t\t\tif (false) row.appendChild(box);", |
| 183 | }, |
| 184 | // Stored, applied to the engines that exist, and forgotten by the next one. The |
| 185 | // exact defect reported: an answer whose lifetime is one chat. |
| 186 | perchat: { |
| 187 | file: 'js/daimond.js', |
| 188 | find: "\t\t\tvar st = window.DaimondHandMode ? DaimondHandMode.standingNet() : '';", |
| 189 | with: "\t\t\tvar st = '';", |
| 190 | }, |
| 191 | // The line saying a command had no network, drawn from a result that says it |
| 192 | // had none. Nothing else in this file touches that block, so it reddens 9 and |
| 193 | // only 9. |
| 194 | deafnote: { |
| 195 | file: 'js/daimond.js', |
| 196 | find: "\t\t\treturn !!(Wasm && Wasm.ran_without_net", |
| 197 | with: "\t\t\treturn false && !!(Wasm && Wasm.ran_without_net", |
| 198 | }, |
| 199 | // No chat can be asked, so the whole section goes. |
| 200 | nosection: { |
| 201 | file: 'js/handmode.js', |
| 202 | find: "\t\tif (typeof cfg.netGet !== 'function') return '';", |
| 203 | with: "\t\tif (typeof cfg.netGet === 'function') return '';", |
| 204 | }, |
| 205 | }; |
| 206 | if (BREAK && !BREAKS[BREAK]) { |
| 207 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 208 | process.exit(2); |
| 209 | } |
| 210 | |
| 211 | let bad = 0; |
| 212 | const check = (pass, name, detail) => { |
| 213 | if (!pass) bad++; |
| 214 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 215 | }; |
| 216 | |
| 217 | const stub = async (page) => { |
| 218 | if (!BREAK) return; |
| 219 | const spec = BREAKS[BREAK]; |
| 220 | const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 221 | // An anchor that is not there exactly once patches nothing and the run would |
| 222 | // pass quietly, which is worse than a red. |
| 223 | if (src.split(spec.find).length !== 2) { |
| 224 | console.error(`break '${BREAK}': its anchor is not in ${spec.file} exactly once`); |
| 225 | process.exit(2); |
| 226 | } |
| 227 | const body = src.replace(spec.find, spec.with); |
| 228 | await page.route('**/' + spec.file, (r) => r.fulfill({ |
| 229 | status: 200, contentType: 'application/javascript', body, |
| 230 | })); |
| 231 | }; |
| 232 | |
| 233 | const s = await open({ |
| 234 | name: 'netchip', |
| 235 | profile: scratch('pw', 'netchip' + (BREAK ? '-' + BREAK : '')), |
| 236 | route: stub, |
| 237 | }); |
| 238 | const { page: p } = s; |
| 239 | if (BREAK) console.log(`\n*** RUNNING UNDER --break ${BREAK}: failures below are the point ***\n`); |
| 240 | |
| 241 | // The popover is rebuilt on every open, so it is opened fresh for each reading |
| 242 | // rather than left up: a stale one would report the state at the moment it opened. |
| 243 | const popOpen = async () => { |
| 244 | await p.evaluate(() => { |
| 245 | const pop = document.getElementById('hand-mode-pop'); |
| 246 | if (!pop.hidden) document.getElementById('hand-mode-chip').click(); |
| 247 | }); |
| 248 | await p.click('#hand-mode-chip'); |
| 249 | await p.waitForTimeout(400); |
| 250 | }; |
| 251 | const popClose = async () => { |
| 252 | await p.evaluate(() => { |
| 253 | const pop = document.getElementById('hand-mode-pop'); |
| 254 | if (!pop.hidden) document.getElementById('hand-mode-chip').click(); |
| 255 | }); |
| 256 | await p.waitForTimeout(250); |
| 257 | }; |
| 258 | const readPop = () => p.evaluate(() => { |
| 259 | const pop = document.getElementById('hand-mode-pop'); |
| 260 | const chip = document.getElementById('hand-mode-chip'); |
| 261 | const heads = [...pop.querySelectorAll('.pop-head')].map(e => e.textContent.trim()); |
| 262 | const now = pop.querySelector('.net-now'); |
| 263 | const opts = [...pop.querySelectorAll('.net-opt')]; |
| 264 | return { |
| 265 | heads, |
| 266 | rungs: pop.querySelectorAll('.mode-row input[type=radio]').length, |
| 267 | now: now ? now.textContent.trim() : '', |
| 268 | btn: opts.map(e => e.textContent.trim()).join(' | '), |
| 269 | hasBtn: opts.length === 3, |
| 270 | chosen: (opts.find(e => e.getAttribute('aria-pressed') === 'true') || {}).textContent || '', |
| 271 | marked: chip.classList.contains('net-cut'), |
| 272 | title: chip.title || '', |
| 273 | aria: chip.getAttribute('aria-label') || '', |
| 274 | engine: (window.DaimondCore && DaimondCore.netState) ? DaimondCore.netState() : '', |
| 275 | }; |
| 276 | }); |
| 277 | |
| 278 | try { |
| 279 | // ARM THE DEFAULT OUTRIGHT. The standing answer is `localStorage` and the |
| 280 | // harness reuses its profile, so a previous run that ended on "always allow" |
| 281 | // left this one starting there -- and checks 3 and 4 duly failed against a chat |
| 282 | // that was working correctly. A probe that assumes its starting state is a probe |
| 283 | // measuring the last run. |
| 284 | // No reload needed: `standing()` reads the key on every call, and no chat engine |
| 285 | // exists yet to be carrying an older answer. |
| 286 | await p.evaluate(() => { try { localStorage.removeItem('daimond-net-standing'); } catch (e) {} }); |
| 287 | |
| 288 | await newChat(s); |
| 289 | // A turn, so the chat has an engine to answer for. Nothing here reads anything |
| 290 | // from outside — that is check 2's whole point. |
| 291 | await p.fill('#chat-input', '@text hello'); |
| 292 | await p.click('#chat-send'); |
| 293 | await p.waitForTimeout(3500); |
| 294 | |
| 295 | // ── 1. The hover names the rung ────────────────────────────── |
| 296 | await popOpen(); |
| 297 | const clean = await readPop(); |
| 298 | await popClose(); |
| 299 | const rung = await p.evaluate(() => |
| 300 | document.getElementById('hand-mode-chip-txt').textContent.trim()); |
| 301 | // THE PAIR. "Names the rung" alone would pass on a tooltip that says "Guarded" |
| 302 | // and nothing else; "is not the old sentence" alone would pass on any rewording. |
| 303 | check(clean.title.includes(rung) && clean.title.length > rung.length + 12, |
| 304 | 'THE HOVER NAMES THIS RUNG AND WHAT IT DOES', |
| 305 | JSON.stringify(clean.title.slice(0, 96))); |
| 306 | // AGAINST THE STRING ITSELF, not against the words it used to hold. Written the |
| 307 | // second way first, and `--break generic` did not redden it: the break restores |
| 308 | // `permmode.chip_help`, whose wording had also been shortened, so a literal from |
| 309 | // the old copy matched nothing and the check could not fail. Read the key. |
| 310 | const generic = await p.evaluate(() => |
| 311 | (window.DaimondI18n ? DaimondI18n.t('permmode.chip_help') : '')); |
| 312 | check(!!generic && clean.title !== generic, |
| 313 | 'and is not the category sentence, whatever that sentence now says', |
| 314 | `chip_help=${JSON.stringify(generic)}`); |
| 315 | |
| 316 | // ── 2 and 6. A clean chat, and the section's altitude ──────── |
| 317 | // Against the app's own string: the head was reworded once already, and a |
| 318 | // literal from the old copy would have made this check unable to fail. |
| 319 | const netHead = await p.evaluate(() => DaimondI18n.t('permmode.net_head')); |
| 320 | check(!!netHead && clean.heads.includes(netHead), |
| 321 | 'THE POPOVER HAS A SECTION FOR THE NETWORK', JSON.stringify(clean.heads)); |
| 322 | check(/nothing/i.test(clean.now) && clean.engine === 'open', |
| 323 | 'A CLEAN CHAT SAYS NOTHING IS WITHHELD, and the engine agrees', |
| 324 | `${JSON.stringify(clean.now.slice(0, 60))} engine=${clean.engine}`); |
| 325 | check(!clean.marked, 'AND THE BUTTON IS NOT MARKED', `net-cut=${clean.marked}`); |
| 326 | // Not a fourth rung. A per-chat state sitting in the ladder would read as a |
| 327 | // setting that outlives the chat, which is the one thing it is not. |
| 328 | check(clean.rungs === 3, |
| 329 | 'and the ladder is still three rungs, with the chat under a head of its own', |
| 330 | `${clean.rungs} radios, heads ${JSON.stringify(clean.heads)}`); |
| 331 | |
| 332 | // ── 3. A chat that has read a stranger's words ─────────────── |
| 333 | const marked = await p.evaluate(() => |
| 334 | !!(window.DaimondCore && DaimondCore.markRead && DaimondCore.markRead())); |
| 335 | check(marked, 'the chat can be marked as having read outside content', |
| 336 | marked ? '' : 'DaimondCore.markRead did not take'); |
| 337 | // A REAL TURN, and not a direct redraw. The mark goes on during a turn and the |
| 338 | // app refreshes the chip when that turn ends; calling the redraw from here would |
| 339 | // prove the drawing works while leaving the wiring that calls it unproved, and |
| 340 | // deleting that wiring would not redden anything. |
| 341 | await p.fill('#chat-input', '@text and again'); |
| 342 | await p.click('#chat-send'); |
| 343 | await p.waitForTimeout(3500); |
| 344 | // Read BEFORE anything is opened — that is the property. |
| 345 | const chipCut = await p.evaluate(() => ({ |
| 346 | marked: document.getElementById('hand-mode-chip').classList.contains('net-cut'), |
| 347 | aria: document.getElementById('hand-mode-chip').getAttribute('aria-label') || '', |
| 348 | })); |
| 349 | await popOpen(); |
| 350 | const cut = await readPop(); |
| 351 | cut.marked = chipCut.marked; |
| 352 | cut.aria = chipCut.aria; |
| 353 | await shot(s, 'netchip-cut'); |
| 354 | check(cut.engine === 'cut', |
| 355 | 'a marked chat has lost the network', `engine=${cut.engine}`); |
| 356 | check(cut.marked, |
| 357 | 'AND THE BUTTON SAYS SO WITHOUT ANYTHING BEING OPENED', |
| 358 | `net-cut=${cut.marked}, aria=${JSON.stringify(cut.aria)}`); |
| 359 | check(/no network|without.*network/i.test(cut.now) && cut.hasBtn, |
| 360 | 'and the section says it in a sentence, with all three ways out beside it', |
| 361 | `${JSON.stringify(cut.now.slice(0, 70))} choices=${JSON.stringify(cut.btn)}`); |
| 362 | |
| 363 | // ── 4. Granting it moves the ENGINE ────────────────────────── |
| 364 | const pick = async (label) => { |
| 365 | await p.evaluate((l) => { |
| 366 | const b = [...document.querySelectorAll('#hand-mode-pop .net-opt')] |
| 367 | .find(x => x.textContent.trim() === l); |
| 368 | if (b) b.click(); |
| 369 | }, label); |
| 370 | await p.waitForTimeout(500); |
| 371 | }; |
| 372 | const ALWAYS = await p.evaluate(() => DaimondI18n.t('permmode.net_always')); |
| 373 | const EACH = await p.evaluate(() => DaimondI18n.t('permmode.net_each')); |
| 374 | const NEVER = await p.evaluate(() => DaimondI18n.t('permmode.net_never')); |
| 375 | await pick(ALWAYS); |
| 376 | const on = await readPop(); |
| 377 | check(on.engine === 'allowed', |
| 378 | 'GRANTING IT MOVES THE ENGINE, not merely the label that was pressed', |
| 379 | `engine=${on.engine}`); |
| 380 | check(!on.marked, |
| 381 | 'and the button stops saying the network is gone', `net-cut=${on.marked}`); |
| 382 | |
| 383 | // ── 5. And it can be taken back ────────────────────────────── |
| 384 | await pick(NEVER); |
| 385 | const off = await readPop(); |
| 386 | check(off.engine === 'refused', |
| 387 | 'AND IT CAN BE TAKEN BACK — the thing that was impossible', |
| 388 | `engine=${off.engine}`); |
| 389 | check(off.marked, |
| 390 | 'with the button marked again, so the two never disagree', |
| 391 | `net-cut=${off.marked}`); |
| 392 | // The default is reachable again, which a two-state toggle would have lost. |
| 393 | await pick(EACH); |
| 394 | const back = await readPop(); |
| 395 | check(back.engine === 'cut', |
| 396 | 'and "ask once per chat" is still reachable, so nothing is a one-way door', |
| 397 | `engine=${back.engine}`); |
| 398 | await popClose(); |
| 399 | |
| 400 | // ── 7. THE ONE THAT MATTERS: a NEW chat is not asked again ─── |
| 401 | // |
| 402 | // The reported defect, in the reporter's words: "I thought we got rid of this |
| 403 | // bullshit!" It had not been got rid of. The answer lived on the chat's engine, |
| 404 | // which is built per chat and does not survive a reload, so every new chat put |
| 405 | // the question again however many times it had been answered. |
| 406 | // |
| 407 | // Measured at the ENGINE and not by watching for a dialog, because `NetStep::Ask` |
| 408 | // is the only branch that raises one: a chat that reports `allowed` while marked |
| 409 | // has had the question answered before it could be put. A dialog watcher would |
| 410 | // also pass on a chat that simply never ran a command. |
| 411 | await popOpen(); |
| 412 | await pick(ALWAYS); |
| 413 | await popClose(); |
| 414 | await newChat(s); |
| 415 | await p.fill('#chat-input', '@text a brand new chat'); |
| 416 | await p.click('#chat-send'); |
| 417 | await p.waitForTimeout(3500); |
| 418 | await p.evaluate(() => DaimondCore.markRead()); |
| 419 | await p.fill('#chat-input', '@text and it reads something'); |
| 420 | await p.click('#chat-send'); |
| 421 | await p.waitForTimeout(3500); |
| 422 | const fresh = await p.evaluate(() => ({ |
| 423 | engine: DaimondCore.netState(), |
| 424 | marked: document.getElementById('hand-mode-chip').classList.contains('net-cut'), |
| 425 | dialog: !![...document.querySelectorAll('.dlg-card')].filter(c => c.getClientRects().length).length, |
| 426 | })); |
| 427 | check(fresh.engine === 'allowed', |
| 428 | 'A CHAT THAT NEVER EXISTED WHEN YOU ANSWERED IS NOT ASKED AGAIN', |
| 429 | `engine=${fresh.engine}`); |
| 430 | check(!fresh.marked && !fresh.dialog, |
| 431 | 'and nothing on screen interrupts it', |
| 432 | `net-cut=${fresh.marked}, dialog=${fresh.dialog}`); |
| 433 | |
| 434 | // ── 8. A YES *THE USER MAKES STANDING* IS THE LAST ONE ─────── |
| 435 | // |
| 436 | // The standing choice above is only reachable by somebody who went looking for |
| 437 | // it in a menu. Nobody did: three reports came from a person answering the |
| 438 | // DIALOG, whose yes went to the chat's own engine and died with it. A person who |
| 439 | // has said yes has consented, and asking again in the next chat treats that |
| 440 | // answer as though it had never been given. |
| 441 | // |
| 442 | // SO THE DIALOG STILL ENDS IT IN ONE GESTURE -- it is now a TICK the person |
| 443 | // makes rather than something that happens to them for saying yes once. The box |
| 444 | // is ticked here, and check 9 is the other half: a yes with the box left alone |
| 445 | // changes nothing outside the command it was given about. |
| 446 | // |
| 447 | // DRIVEN THROUGH THE APP'S OWN GATE, `window.__daimondEgressAllowed` -- the |
| 448 | // global the wasm calls when a command wants the network -- and the dialog it |
| 449 | // raises is clicked like a person clicks it. The first version of this called |
| 450 | // `setStandingNet` directly instead, and `--break yesnotsticky` reddened |
| 451 | // NOTHING: the check proved the recorder worked and said nothing about whether |
| 452 | // the dialog ever reaches it, which is the entire defect. A break that does not |
| 453 | // go red is a finding, and this one's finding was about the check. |
| 454 | await p.evaluate(() => { try { localStorage.removeItem('daimond-net-standing'); } catch (e) {} }); |
| 455 | await newChat(s); |
| 456 | await p.fill('#chat-input', '@text before the yes'); |
| 457 | await p.click('#chat-send'); |
| 458 | await p.waitForTimeout(3500); |
| 459 | const gate = p.evaluate(() => window.__daimondEgressAllowed(JSON.stringify({ |
| 460 | tool: 'run_net', url: 'cargo build --release', detail: '/home/jason/usr/code', |
| 461 | }))); |
| 462 | await p.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 463 | // THE TICK, then OK. Clicked as a person clicks it, on the box in the card that |
| 464 | // is actually up: a `standing: true` posted into the handler from here would |
| 465 | // prove the recorder works and say nothing about whether any control on screen |
| 466 | // reaches it, which is the same fault `--break yesnotsticky` was written to |
| 467 | // catch the first time. |
| 468 | const tickable = await p.evaluate(() => { |
| 469 | const card = [...document.querySelectorAll('.dlg-card')].filter(c => c.getClientRects().length).pop(); |
| 470 | const box = card && card.querySelector('.net-standing-box'); |
| 471 | if (!box) return false; |
| 472 | box.click(); |
| 473 | return !!box.checked; |
| 474 | }); |
| 475 | check(tickable, |
| 476 | 'THE DIALOG OFFERS A CONTROL THE USER PRESSES to make an answer standing', |
| 477 | tickable ? '' : 'no .net-standing-box in the card, or it would not tick'); |
| 478 | await p.evaluate(() => { |
| 479 | const card = [...document.querySelectorAll('.dlg-card')].filter(c => c.getClientRects().length).pop(); |
| 480 | card.querySelector('.dlg-ok').click(); |
| 481 | }); |
| 482 | const verdict = await gate; |
| 483 | check(verdict === 'allow-net', |
| 484 | 'the network dialog is raised and a yes comes back as a yes', String(verdict)); |
| 485 | await newChat(s); |
| 486 | await p.fill('#chat-input', '@text a chat made after the yes'); |
| 487 | await p.click('#chat-send'); |
| 488 | await p.waitForTimeout(3500); |
| 489 | await p.evaluate(() => DaimondCore.markRead()); |
| 490 | const after = await p.evaluate(() => DaimondCore.netState()); |
| 491 | check(after === 'allowed', |
| 492 | 'A YES THE USER TICKED IS THE LAST ONE — a later chat is never asked', |
| 493 | `engine=${after}`); |
| 494 | |
| 495 | // ── 9. AND A YES NOBODY TICKED CHANGES NOTHING ELSE ────────── |
| 496 | // |
| 497 | // THE CHECK THAT MATTERS, and the one nothing above it could have caught. Until |
| 498 | // 2026-08-28 ANY yes in this dialog called `setStandingNet('allow')`, which |
| 499 | // writes `daimond-net-standing` -- the app-wide standing answer -- and pushes |
| 500 | // `allow` into every chat that already has an engine. So: |
| 501 | // |
| 502 | // two chats open; in one the user is asked about a command and says NO; in the |
| 503 | // other they are asked about a DIFFERENT command and say yes; and the first |
| 504 | // chat's refusal is gone. Its next command reaches the network, nobody is |
| 505 | // asked, nothing says the answer was reversed, and the permissions button |
| 506 | // reads "Always" -- a setting they never chose, surviving every reload. |
| 507 | // |
| 508 | // src/tools.rs says of `override_net_consent`, which that path ends at, that |
| 509 | // NOTHING IN THE TOOL LOOP MAY CALL IT. This dialog is the tool loop. |
| 510 | // |
| 511 | // TWO ASSERTIONS AND NOT ONE, because either alone is satisfied by half a fix: |
| 512 | // the stored policy is untouched, AND the other chat's engine still refuses. A |
| 513 | // build that stopped writing the key but went on calling `netApplyAll` would |
| 514 | // pass the first and reverse the user's no all the same. |
| 515 | // |
| 516 | // HOW THE REFUSING CHAT IS PUT IN THAT STATE: the popover's own "Never" chip, |
| 517 | // and then the stored key removed from under it. `setStanding` writes the key |
| 518 | // AND every engine, so taking the key away afterwards leaves exactly what a no |
| 519 | // in the dialog leaves -- one engine holding a refusal, and no stored policy. |
| 520 | // There is no other way in from a served page: a no at the dialog is answered to |
| 521 | // the WASM, which is not in the loop when the gate is driven from here. |
| 522 | const focusId = () => p.evaluate(() => { |
| 523 | try { |
| 524 | const f = window.DaimondAttach && window.DaimondAttach.focus(); |
| 525 | return (f && f.kind === 'chat') ? String(f.id) : ''; |
| 526 | } catch (e) { return ''; } |
| 527 | }); |
| 528 | const goTo = async (id) => { |
| 529 | await p.evaluate((cid) => { |
| 530 | const esc = (window.CSS && CSS.escape) ? CSS.escape(cid) : cid; |
| 531 | const box = document.querySelector('.session-box[data-id="' + esc + '"]'); |
| 532 | if (box) box.click(); |
| 533 | }, id); |
| 534 | await p.waitForTimeout(800); |
| 535 | }; |
| 536 | |
| 537 | // Cleared BEFORE the chat is made: `ensureApp` reads the standing answer into |
| 538 | // each new engine, so a chat born under check 8's "always" would start granted. |
| 539 | await p.evaluate(() => { try { localStorage.removeItem('daimond-net-standing'); } catch (e) {} }); |
| 540 | await newChat(s); |
| 541 | const chatNo = await focusId(); |
| 542 | await p.fill('#chat-input', '@text the chat that says no'); |
| 543 | await p.click('#chat-send'); |
| 544 | await p.waitForTimeout(3500); |
| 545 | await p.evaluate(() => DaimondCore.markRead()); |
| 546 | await popOpen(); |
| 547 | await pick(NEVER); |
| 548 | await popClose(); |
| 549 | await p.evaluate(() => { try { localStorage.removeItem('daimond-net-standing'); } catch (e) {} }); |
| 550 | const noBefore = await p.evaluate(() => DaimondCore.netState()); |
| 551 | check(noBefore === 'refused' && !!chatNo, |
| 552 | 'a chat has said no, and nothing is stored — the state a no in the dialog leaves', |
| 553 | `engine=${noBefore} id=${JSON.stringify(chatNo)}`); |
| 554 | |
| 555 | // The OTHER chat, a different command, and OK with the box LEFT ALONE. |
| 556 | await newChat(s); |
| 557 | await p.fill('#chat-input', '@text the chat that says yes'); |
| 558 | await p.click('#chat-send'); |
| 559 | await p.waitForTimeout(3500); |
| 560 | const gate2 = p.evaluate(() => window.__daimondEgressAllowed(JSON.stringify({ |
| 561 | tool: 'run_net', url: 'curl https://elsewhere.test/x', detail: '/home/jason/usr', |
| 562 | }))); |
| 563 | await p.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 564 | const untouched = await p.evaluate(() => { |
| 565 | const card = [...document.querySelectorAll('.dlg-card')].filter(c => c.getClientRects().length).pop(); |
| 566 | const box = card && card.querySelector('.net-standing-box'); |
| 567 | return !!card && (!box || box.checked === false); |
| 568 | }); |
| 569 | await shot(s, 'netchip-oneoff'); |
| 570 | await p.evaluate(() => { |
| 571 | const card = [...document.querySelectorAll('.dlg-card')].filter(c => c.getClientRects().length).pop(); |
| 572 | card.querySelector('.dlg-ok').click(); |
| 573 | }); |
| 574 | const verdict2 = await gate2; |
| 575 | check(untouched && verdict2 === 'allow-net', |
| 576 | 'the box starts UNTICKED and a plain yes is still a yes for this command', |
| 577 | `unticked=${untouched} verdict=${verdict2}`); |
| 578 | const stored = await p.evaluate(() => { |
| 579 | try { return localStorage.getItem('daimond-net-standing'); } catch (e) { return 'unreadable'; } |
| 580 | }); |
| 581 | check(!stored, |
| 582 | 'A ONE-OFF YES WRITES NO STANDING POLICY', |
| 583 | `daimond-net-standing=${JSON.stringify(stored)}`); |
| 584 | await goTo(chatNo); |
| 585 | const noAfter = await p.evaluate(() => DaimondCore.netState()); |
| 586 | check(noAfter === 'refused', |
| 587 | "AND THE OTHER CHAT'S NO IS STILL A NO", |
| 588 | `engine=${noAfter}`); |
| 589 | // ── 10. AND THE COMMAND THAT RAN WITHOUT IT SAYS SO ────────── |
| 590 | // |
| 591 | // Everything above is about the CHAT's state, on a button in the header. None |
| 592 | // of it is what a person meets: they watch a `cargo build` stop halfway with |
| 593 | // "failed to fetch" and nothing anywhere says why. `src/tools.rs` writes a note |
| 594 | // into the result for exactly that reason -- and wrote it FOR THE MODEL, in |
| 595 | // English, inside brackets, so the person learned the reason only if the model |
| 596 | // chose to relay it. A capability with a model-facing surface and no |
| 597 | // user-facing one, recorded on 2026-08-13 and still standing on 2026-08-28. |
| 598 | // |
| 599 | // THE FIXTURE IS THE CONTRACT ITSELF. `NO_NET_MARK` is read out of the Rust, |
| 600 | // so this cannot go green against a mark the engine no longer writes; that the |
| 601 | // mark opens the note it stands for is held in Rust by |
| 602 | // `test_the_no_network_mark_opens_the_note_and_finds_it_in_a_result`, and the |
| 603 | // whole path -- a real command, a real hand, a real fence -- by |
| 604 | // `dev/verify_handrun.mjs`. What is asked here is the half neither of those |
| 605 | // can see: whether the person is told. |
| 606 | const MARK = (() => { |
| 607 | const rs = fs.readFileSync(path.join(WWW, '..', 'src/tools.rs'), 'utf8'); |
| 608 | const m = rs.match(/pub const NO_NET_MARK: &str = "([^"]+)"/); |
| 609 | return m ? m[1] : ''; |
| 610 | })(); |
| 611 | check(!!MARK, 'the engine\'s own mark for a command that had no network was read', |
| 612 | JSON.stringify(MARK)); |
| 613 | |
| 614 | const said = await p.evaluate((mark) => { |
| 615 | const out = {}; |
| 616 | DaimondCore.drawToolResult('run', |
| 617 | 'error: failed to fetch `serde`\n[exit code: 101]\n' + mark + ' the turn read outside ' |
| 618 | + 'content, so this command ran without it.]', 'done'); |
| 619 | // The LAST block, by position rather than by `:last-of-type`, which is about |
| 620 | // element type and would answer about whatever div happens to be last. |
| 621 | const last = () => [...document.querySelectorAll('.tool-block')].pop(); |
| 622 | out.blocks = document.querySelectorAll('.tool-block').length; |
| 623 | let line = last() ? last().querySelector('.tool-nonet') : null; |
| 624 | out.withNote = line ? (line.textContent || '').trim() : ''; |
| 625 | out.shown = !!(line && line.getClientRects().length); |
| 626 | DaimondCore.drawToolResult('run', 'Compiling daimond v0.1.0\n[exit code: 0]', 'done'); |
| 627 | line = last() ? last().querySelector('.tool-nonet') : null; |
| 628 | out.without = line ? (line.textContent || '').trim() : ''; |
| 629 | out.sentence = window.DaimondI18n ? DaimondI18n.t('chat.tool_no_net') : ''; |
| 630 | return out; |
| 631 | }, MARK); |
| 632 | check(!!said.withNote && said.shown, |
| 633 | 'A COMMAND THAT RAN WITH NO NETWORK SAYS SO ON ITS OWN BLOCK, to the person', |
| 634 | `${said.blocks} block(s) drawn — ${JSON.stringify(said.withNote.slice(0, 80))}`); |
| 635 | // In the app's own words and not the model's: the bracketed note is written for |
| 636 | // a model to act on, and a person reading it is reading somebody else's post. |
| 637 | check(said.withNote === said.sentence && said.sentence.length > 40 |
| 638 | && !said.withNote.includes(MARK), |
| 639 | 'and in the app\'s own sentence, from the catalogue, not the model\'s note', |
| 640 | JSON.stringify(said.sentence.slice(0, 80))); |
| 641 | // The other half, and it is what stops the line becoming noise: a command that |
| 642 | // HAD the network says nothing at all. |
| 643 | check(said.without === '', |
| 644 | 'and a command that had the network says nothing about it', |
| 645 | JSON.stringify(said.without)); |
| 646 | |
| 647 | // The sentence exists in all eight catalogues. `tOr` draws correct English when |
| 648 | // a key is in no table, so a missing translation looks exactly like a finished |
| 649 | // one and nothing reports it. |
| 650 | const LOCALES = ['en', 'es', 'de', 'fr', 'pt-BR', 'zh-Hans', 'ja', 'ko']; |
| 651 | const gaps = LOCALES.filter((code) => { |
| 652 | const src = fs.readFileSync(path.join(WWW, 'i18n', code + '.js'), 'utf8'); |
| 653 | return !/'chat\.tool_no_net':\s*'[^']{20,}'/.test(src); |
| 654 | }); |
| 655 | check(gaps.length === 0, |
| 656 | 'and it is in all eight catalogues, not in English only', |
| 657 | gaps.join(', ')); |
| 658 | } finally { |
| 659 | await s.close(); |
| 660 | } |
| 661 | |
| 662 | console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed'); |
| 663 | process.exit(bad ? 1 : 0); |