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