oxedyne/daimond/dev/verify_hand.mjs
39.7 KiB, 1 run
created by r2519314175:465, 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_hand.mjs — the machine hand's relay, end to end, without the Rust |
| 2 | // binary. |
| 3 | // |
| 4 | // The relay's whole job is the part a correct hand never exercises: output that |
| 5 | // arrives in order and stays attributable, a gap that is announced rather than |
| 6 | // hidden, and the several ways a native messaging host can vanish. So this runs |
| 7 | // against hand/install/mock_host.py, which speaks the real framing and the real |
| 8 | // messages and can be told to misbehave on purpose. |
| 9 | // |
| 10 | // It launches a real Chrome with the real unpacked extension, writes the host |
| 11 | // manifest into the test profile's own NativeMessagingHosts directory — which is |
| 12 | // exactly where a browser started with --user-data-dir looks — and drives the |
| 13 | // relay from a page on an allowed origin. The grant window is clicked for real: |
| 14 | // unlike a site approval, this one has no second Chrome prompt behind it, so the |
| 15 | // whole flow is reachable from a test. |
| 16 | // |
| 17 | // It also drives the boundary itself, which is the half a working day never |
| 18 | // touches: a hostile origin's probe, a grant that must not carry from one origin |
| 19 | // to the next, the four shapes of exec this end refuses, and the wording the |
| 20 | // grant window is only allowed to use when the machine can back it. |
| 21 | // |
| 22 | // Needs nothing else running, and takes the first free port from 8877 rather |
| 23 | // than the dev server's, so it can be run beside one. Run it headed, under xvfb: |
| 24 | // xvfb-run -a -s "-screen 0 1400x900x24" node dev/verify_hand.mjs |
| 25 | import path from 'node:path'; |
| 26 | import os from 'node:os'; |
| 27 | import fs from 'node:fs'; |
| 28 | import http from 'node:http'; |
| 29 | import { pathToFileURL } from 'node:url'; |
| 30 | |
| 31 | const PW = process.env.DAIMOND_PW |
| 32 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 33 | const { chromium } = await import(pathToFileURL(PW).href); |
| 34 | const CHROME = process.env.DAIMOND_CHROME |
| 35 | || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 36 | |
| 37 | import { fileURLToPath } from 'node:url'; |
| 38 | // Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever |
| 39 | // `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed |
| 40 | // run under `xvfb-run` still went to the compositor and opened a window on the owner's |
| 41 | // desktop. Importing this strips the two variables from `process.env`, which is all a |
| 42 | // launcher that spreads `process.env` needs. See dev/display.mjs. |
| 43 | import './display.mjs'; |
| 44 | const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); // this checkout, not one developer's home |
| 45 | const EXT = `${ROOT}/ext`; |
| 46 | // The SHIPPED manifest names one origin and it is not this test server. The dev |
| 47 | // origins live in the generated build alone, so that the release artefact cannot |
| 48 | // carry them -- see dev/extdev.mjs, and hand/REVIEW.md §1.6 for what they cost. |
| 49 | const { extDev } = await import(pathToFileURL(`${ROOT}/dev/extdev.mjs`).href); |
| 50 | const INSTALL = `${ROOT}/hand/install`; |
| 51 | // Not /tmp -- see the SCRATCH note in harness.mjs. |
| 52 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond'); |
| 53 | // The mock reads its configuration, and writes its log, BESIDE ITSELF. Two runs |
| 54 | // of this file at once would each be told what to do by the other, which is not |
| 55 | // a hypothetical: it happened, and it looked like the relay dropping chunks. So |
| 56 | // each run gets its own copy of the mock and its own pair of files. The copy is |
| 57 | // made on every launch, so a change to hand/install/mock_host.py is picked up. |
| 58 | // The extension's reload grace, read from the file rather than repeated here: a |
| 59 | // wait that disagreed with the hold would pass or fail for a reason that is not |
| 60 | // the property. See ext/hand.js, "The reload grace". |
| 61 | const HOLD_MS = (() => { |
| 62 | const src = fs.readFileSync(`${ROOT}/ext/hand.js`, 'utf8'); |
| 63 | const m = /const HOLD_MS = (\d+);/.exec(src); |
| 64 | if (!m) { console.error('ext/hand.js no longer names HOLD_MS; the waits below cannot be aimed'); process.exit(2); } |
| 65 | return Number(m[1]); |
| 66 | })(); |
| 67 | const MOCKDIR = path.join(SCRATCH, `verify-hand-mock-${process.pid}`); |
| 68 | const MOCK = path.join(MOCKDIR, 'mock_host.py'); |
| 69 | const CFG = path.join(MOCKDIR, 'mock_cfg.json'); |
| 70 | const MOCKLOG = path.join(MOCKDIR, 'mock_host.log'); |
| 71 | fs.rmSync(MOCKDIR, { recursive: true, force: true }); |
| 72 | fs.mkdirSync(MOCKDIR, { recursive: true }); |
| 73 | fs.copyFileSync(`${INSTALL}/mock_host.py`, MOCK); |
| 74 | fs.chmodSync(MOCK, 0o755); |
| 75 | const PROFILE = path.join(SCRATCH, 'verify-hand'); |
| 76 | // The stub page only has to be on an origin the manifest lets speak to the |
| 77 | // extension. It is not the app: nothing here needs the app -- and it is not the |
| 78 | // dev server either, so the two must not fight over its port. The port is CHOSEN |
| 79 | // below, from the first free one, and the dev build is then generated to trust |
| 80 | // whichever that was. That is what lets this run beside `dev/serve.mjs`. |
| 81 | const FIRST = Number(process.env.HAND_PORT || 8877); |
| 82 | const EXTID = 'mpliijponglmmffjnonahhignkpkhmij'; |
| 83 | let PORT = FIRST; |
| 84 | let HOSTILE_PORT = 0; |
| 85 | let APP = ''; |
| 86 | let APP2 = ''; |
| 87 | let HOSTILE = ''; |
| 88 | let EXT_DEV = ''; |
| 89 | |
| 90 | const ok = [], bad = []; |
| 91 | const check = (name, pass, detail) => { |
| 92 | (pass ? ok : bad).push(name); |
| 93 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 94 | }; |
| 95 | |
| 96 | /// The page under test: it opens a port to the extension and keeps every |
| 97 | /// message, in the order it arrived, for us to read afterwards. |
| 98 | const PAGE = `<!doctype html><meta charset="utf-8"><title>hand</title> |
| 99 | <body><h1>hand harness</h1><script> |
| 100 | window.__seen = []; |
| 101 | window.__port = null; |
| 102 | window.__open = function () { |
| 103 | const id = document.documentElement.dataset.daimondHands; |
| 104 | if (!id) return 'no extension'; |
| 105 | window.__seen = []; |
| 106 | window.__port = chrome.runtime.connect(id, { name: 'daimond-hand' }); |
| 107 | window.__port.onMessage.addListener((m) => window.__seen.push(m)); |
| 108 | window.__port.onDisconnect.addListener(() => window.__seen.push({ t: '__gone' })); |
| 109 | return 'ok'; |
| 110 | }; |
| 111 | // A port whose extension end has already gone throws here, and Chrome does |
| 112 | // not tell the page it went until a turn later -- so a send that crosses that |
| 113 | // moment is a race this harness has lost before (it died with "Attempting to |
| 114 | // use a disconnected port object", killing the whole run). The answer is |
| 115 | // reported rather than thrown: every check reads what the extension SENT, and |
| 116 | // a post that could not be made is not one of them. |
| 117 | window.__say = function (m) { |
| 118 | try { window.__port.postMessage(m); return 'sent'; } |
| 119 | catch (e) { return 'gone: ' + ((e && e.message) || e); } |
| 120 | }; |
| 121 | // A well-formed exec, with anything overridden and anything dropped. The fence |
| 122 | // is a real one: the hand refuses an empty fence and a cwd outside it, and so, |
| 123 | // now, does the relay. |
| 124 | window.__exec = function (id, argv, over, drop) { |
| 125 | const m = { t: 'exec', id, argv, cwd: '/tmp', env: [], stdin: null, |
| 126 | timeout_ms: 60000, capture: 'both', |
| 127 | fence: { rw: ['/tmp'], ro: [], deny: [], net: false } }; |
| 128 | Object.assign(m, over || {}); |
| 129 | for (const k of (drop || [])) delete m[k]; |
| 130 | window.__say(m); |
| 131 | }; |
| 132 | </script></body>`; |
| 133 | |
| 134 | /// The reviewer's probe: a bare hostile page that knows the extension id -- it is |
| 135 | /// pinned in the manifest and public -- and simply asks. Everything it can try |
| 136 | /// is tried, and what it got is left on the window. |
| 137 | const PROBE = `<!doctype html><meta charset="utf-8"><title>probe</title> |
| 138 | <body><h1>hostile</h1><script> |
| 139 | window.__probe = { announced: null, runtime: null, connect: null, message: null }; |
| 140 | // What the page can see without touching anything, so the control can be run |
| 141 | // from an allowed origin without opening a grant question nobody answers. |
| 142 | window.__peek = function () { |
| 143 | return { |
| 144 | announced: document.documentElement.dataset.daimondHands || null, |
| 145 | runtime: (window.chrome && chrome.runtime && chrome.runtime.connect) ? 'present' : 'absent', |
| 146 | }; |
| 147 | }; |
| 148 | window.__try = function () { |
| 149 | const id = '${EXTID}'; |
| 150 | window.__probe.announced = document.documentElement.dataset.daimondHands || null; |
| 151 | window.__probe.runtime = (window.chrome && chrome.runtime) ? 'present' : 'absent'; |
| 152 | try { |
| 153 | const port = chrome.runtime.connect(id, { name: 'daimond-hand' }); |
| 154 | window.__probe.connect = 'port'; |
| 155 | port.onMessage.addListener((m) => { window.__probe.connect = 'answered:' + m.t; }); |
| 156 | port.onDisconnect.addListener(() => { window.__probe.connect = 'disconnected'; }); |
| 157 | port.postMessage({ t: 'hello', proto: 1, client: 'probe' }); |
| 158 | } catch (e) { |
| 159 | window.__probe.connect = 'threw:' + ((e && e.message) || e); |
| 160 | } |
| 161 | try { |
| 162 | chrome.runtime.sendMessage(id, { cmd: 'ping' }, (r) => { |
| 163 | window.__probe.message = chrome.runtime.lastError |
| 164 | ? 'error:' + chrome.runtime.lastError.message : JSON.stringify(r); |
| 165 | }); |
| 166 | } catch (e) { |
| 167 | window.__probe.message = 'threw:' + ((e && e.message) || e); |
| 168 | } |
| 169 | return window.__probe; |
| 170 | }; |
| 171 | </script></body>`; |
| 172 | |
| 173 | /// Serves the harness page, and the hostile probe at `/probe` so the same probe |
| 174 | /// can be run from an origin that IS allowed -- otherwise "it did not work" |
| 175 | /// proves only that the probe does not work. |
| 176 | async function serve(port, host) { |
| 177 | const s = http.createServer((req, res) => { |
| 178 | res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); |
| 179 | res.end(/^\/probe/.test(req.url || '') ? PROBE : PAGE); |
| 180 | }); |
| 181 | await new Promise((resolve, reject) => { |
| 182 | s.once('error', reject); |
| 183 | s.listen(port, host, resolve); |
| 184 | }); |
| 185 | return s; |
| 186 | } |
| 187 | |
| 188 | /// Takes the first free port from `from`, so a dev server that already holds one |
| 189 | /// is left alone rather than fought over. |
| 190 | async function serveFree(from) { |
| 191 | for (let port = from; port < from + 40; port++) { |
| 192 | try { |
| 193 | return { s: await serve(port, '127.0.0.1'), port }; |
| 194 | } catch (e) { |
| 195 | if (e.code !== 'EADDRINUSE') { throw e; } |
| 196 | } |
| 197 | } |
| 198 | console.error(`No free port from ${from}. Set HAND_PORT.`); |
| 199 | process.exit(2); |
| 200 | } |
| 201 | |
| 202 | const servers = []; |
| 203 | const first = await serveFree(FIRST); |
| 204 | servers.push(first.s); |
| 205 | PORT = first.port; |
| 206 | const second = await serveFree(PORT + 1); |
| 207 | servers.push(second.s); |
| 208 | HOSTILE_PORT = second.port; |
| 209 | |
| 210 | APP = `http://127.0.0.1:${PORT}`; |
| 211 | // The SECOND allowed origin. Same server, same machine, different origin string |
| 212 | // -- which is the whole of the per-origin grant: allowed once from 127.0.0.1, |
| 213 | // the hand reached the host from localhost with no window shown at all. |
| 214 | APP2 = `http://localhost:${PORT}`; |
| 215 | // And one that is allowed nowhere. The reviewer's probe was a bare hostile HTML |
| 216 | // file served from a port the extension trusted; this is the same file served |
| 217 | // from one it does not. |
| 218 | HOSTILE = `http://127.0.0.1:${HOSTILE_PORT}`; |
| 219 | // The dev build trusts the port this run actually took. Nothing else about it |
| 220 | // differs from the one a developer loads -- see dev/extdev.mjs. |
| 221 | EXT_DEV = await extDev(PORT); |
| 222 | |
| 223 | // `localhost` is the other spelling of the same machine, and which family it |
| 224 | // resolves to is the resolver's business, so both are answered. |
| 225 | try { servers.push(await serve(PORT, '::1')); } catch (e) { /* no IPv6 loopback here */ } |
| 226 | |
| 227 | /// Points the profile's native messaging host at the mock, with the behaviour |
| 228 | /// this part of the test needs. The host reads its configuration when it |
| 229 | /// starts, so a fresh port picks up a fresh setting. |
| 230 | const HOSTS = path.join(PROFILE, 'NativeMessagingHosts'); |
| 231 | function register(cfg) { |
| 232 | fs.mkdirSync(HOSTS, { recursive: true }); |
| 233 | fs.writeFileSync(path.join(HOSTS, 'com.oxedyne.daimond.hand.json'), JSON.stringify({ |
| 234 | name: 'com.oxedyne.daimond.hand', |
| 235 | description: 'Mock hand for verify_hand.mjs.', |
| 236 | path: MOCK, |
| 237 | type: 'stdio', |
| 238 | allowed_origins: ['chrome-extension://mpliijponglmmffjnonahhignkpkhmij/'], |
| 239 | }, null, '\t') + '\n'); |
| 240 | fs.writeFileSync(CFG, JSON.stringify(cfg || {}, null, '\t') + '\n'); |
| 241 | try { fs.rmSync(MOCKLOG); } catch (e) { /* first run */ } |
| 242 | } |
| 243 | function unregister() { |
| 244 | try { fs.rmSync(path.join(HOSTS, 'com.oxedyne.daimond.hand.json')); } catch (e) {} |
| 245 | } |
| 246 | |
| 247 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 248 | fs.mkdirSync(PROFILE, { recursive: true }); |
| 249 | // `caps` is what the hand claims it can enforce, and the grant window's |
| 250 | // wording is chosen from it. `root:` is how the granted folder travels, since |
| 251 | // wire.rs has no field for one. |
| 252 | register({ chunks: 3, caps: ['fence:none', 'root:/tmp'] }); |
| 253 | |
| 254 | const b = await chromium.launchPersistentContext(PROFILE, { |
| 255 | executablePath: CHROME, |
| 256 | headless: false, |
| 257 | args: ['--no-sandbox', '--disable-dev-shm-usage', |
| 258 | `--disable-extensions-except=${EXT_DEV}`, `--load-extension=${EXT_DEV}`], |
| 259 | viewport: { width: 1200, height: 800 }, |
| 260 | }); |
| 261 | |
| 262 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 263 | |
| 264 | async function waitSW() { |
| 265 | for (let i = 0; i < 100 && !b.serviceWorkers().length; i++) await sleep(100); |
| 266 | return b.serviceWorkers()[0]; |
| 267 | } |
| 268 | |
| 269 | /// Everything the page has been sent so far. |
| 270 | async function seen(page) { |
| 271 | return await page.evaluate(() => window.__seen); |
| 272 | } |
| 273 | |
| 274 | /// Waits for a message of a given `t`, and returns the whole transcript. |
| 275 | async function until(page, t, ms = 12000) { |
| 276 | const until = Date.now() + ms; |
| 277 | while (Date.now() < until) { |
| 278 | const all = await seen(page); |
| 279 | if (all.some((m) => m.t === t)) return all; |
| 280 | await sleep(150); |
| 281 | } |
| 282 | return await seen(page); |
| 283 | } |
| 284 | |
| 285 | /// Finds the grant window, reads every word of it, and answers. It is the |
| 286 | /// extension's own page, so a click here is a real click, and for this question |
| 287 | /// there is no second Chrome prompt behind it — this window IS the approval. |
| 288 | /// |
| 289 | /// The whole window is returned, not only its heading: what it PROMISES is the |
| 290 | /// thing under test, and the promise is in the body and the small print. |
| 291 | async function handWindow(answer = 'allow', ms = 8000) { |
| 292 | const until = Date.now() + ms; |
| 293 | while (Date.now() < until) { |
| 294 | for (const p of b.pages()) { |
| 295 | if (/grant\.html/.test(p.url())) { |
| 296 | await p.waitForLoadState('domcontentloaded'); |
| 297 | await sleep(300); |
| 298 | const said = await p.evaluate(() => { |
| 299 | const at = (id) => { |
| 300 | const n = document.getElementById(id); |
| 301 | return n && !n.hidden ? (n.textContent || '') : ''; |
| 302 | }; |
| 303 | return { head: at('head'), host: at('host'), scope: at('scope'), body: at('body'), fine: at('fine') }; |
| 304 | }); |
| 305 | said.url = p.url(); |
| 306 | await p.click(answer === 'allow' ? '#allow' : '#deny'); |
| 307 | return said; |
| 308 | } |
| 309 | } |
| 310 | await sleep(150); |
| 311 | } |
| 312 | return null; |
| 313 | } |
| 314 | |
| 315 | /// The grant window as it would be drawn for a hand reporting `caps`, without |
| 316 | /// waiting for a machine that reports them. Chrome's own page, opened directly, |
| 317 | /// which is how dev/verify_ext_i18n.mjs reads this window too. |
| 318 | async function wordingFor(caps) { |
| 319 | const p = await b.newPage(); |
| 320 | await p.goto(`chrome-extension://${extId}/grant.html?nonce=probe&kind=hand` |
| 321 | + `&origin=${encodeURIComponent(APP)}&caps=${encodeURIComponent(caps)}`, |
| 322 | { waitUntil: 'domcontentloaded' }); |
| 323 | await sleep(400); |
| 324 | const said = await p.evaluate(() => ({ |
| 325 | body: (document.getElementById('body') || {}).textContent || '', |
| 326 | fine: (document.getElementById('fine') || {}).textContent || '', |
| 327 | scope: (document.getElementById('scope') || {}).textContent || '', |
| 328 | })); |
| 329 | await p.close(); |
| 330 | return said; |
| 331 | } |
| 332 | |
| 333 | /// Everything the page has been told, with the refusals easy to find. |
| 334 | async function refusalFor(page, id) { |
| 335 | const all = await seen(page); |
| 336 | return all.find((m) => m.t === 'refused' && m.id === id) || null; |
| 337 | } |
| 338 | |
| 339 | /// The extension's own popup, opened as a page. Messages to the broker have to |
| 340 | /// come from a page like this one: Chrome does not deliver a runtime message |
| 341 | /// back to the context that sent it, so the service worker cannot ask itself. |
| 342 | let extId = ''; |
| 343 | async function popup() { |
| 344 | const p = await b.newPage(); |
| 345 | await p.goto(`chrome-extension://${extId}/popup.html`); |
| 346 | await sleep(700); |
| 347 | return p; |
| 348 | } |
| 349 | |
| 350 | try { |
| 351 | const sw = await waitSW(); |
| 352 | extId = sw ? new URL(sw.url()).host : ''; |
| 353 | check('the broker service worker started', !!sw); |
| 354 | check('the manifest asks for nativeMessaging', !!sw |
| 355 | && (await sw.evaluate(() => chrome.runtime.getManifest().permissions.includes('nativeMessaging')))); |
| 356 | check('the relay is loaded into the worker', !!sw |
| 357 | && (await sw.evaluate(() => typeof globalThis.DaimondHand === 'object'))); |
| 358 | |
| 359 | const page = await b.newPage(); |
| 360 | await page.goto(APP + '/', { waitUntil: 'domcontentloaded' }); |
| 361 | await sleep(800); |
| 362 | check('the extension announced itself to the page', |
| 363 | await page.evaluate(() => !!document.documentElement.dataset.daimondHands)); |
| 364 | |
| 365 | // ── What ships ────────────────────────────────────────────────── |
| 366 | // |
| 367 | // The manifest under test here is the GENERATED one, which has the dev |
| 368 | // origins in it on purpose. The one that ships is the file in the tree, and |
| 369 | // the only thing that keeps a user safe from a stray server on 8777 is that |
| 370 | // it does not name it. So the file itself is read. |
| 371 | { |
| 372 | const man = JSON.parse(fs.readFileSync(`${EXT}/manifest.json`, 'utf8')); |
| 373 | const pats = [].concat( |
| 374 | (man.externally_connectable || {}).matches || [], |
| 375 | ...(man.content_scripts || []).map((cs) => cs.matches || [])); |
| 376 | const loop = pats.filter((p) => /(127\.0\.0\.1|localhost|\[::1\]|0\.0\.0\.0)/.test(p)); |
| 377 | check('the shipped manifest names no loopback origin', loop.length === 0, loop.join(', ')); |
| 378 | check('and it still names the one origin that is real', |
| 379 | pats.every((p) => /^https:\/\/daimond\.oxedyne\.com\//.test(p)), pats.join(', ')); |
| 380 | const dev = JSON.parse(fs.readFileSync(`${EXT_DEV}/manifest.json`, 'utf8')); |
| 381 | check('the dev build is the one that carries them', |
| 382 | (dev.externally_connectable.matches || []).filter((p) => /127\.0\.0\.1|localhost/.test(p)).length === 2, |
| 383 | JSON.stringify(dev.externally_connectable.matches)); |
| 384 | } |
| 385 | |
| 386 | // ── A hostile origin ──────────────────────────────────────────── |
| 387 | // |
| 388 | // The reviewer's probe, from a port the extension does not trust. Chrome is |
| 389 | // the doorman and this is what it is for; the point of running it is that |
| 390 | // the same page, from an origin that IS trusted, gets straight through. |
| 391 | { |
| 392 | const eve = await b.newPage(); |
| 393 | await eve.goto(HOSTILE + '/probe', { waitUntil: 'domcontentloaded' }); |
| 394 | await sleep(500); |
| 395 | const got = await eve.evaluate(() => window.__try()); |
| 396 | await sleep(900); |
| 397 | const after = await eve.evaluate(() => window.__probe); |
| 398 | check('a hostile origin is not content-scripted at all', !got.announced, String(got.announced)); |
| 399 | check('a hostile origin cannot reach the extension at all', |
| 400 | after.runtime === 'absent' || /threw|disconnected/.test(String(after.connect)), |
| 401 | JSON.stringify(after)); |
| 402 | check('and nothing answers its message either', |
| 403 | !after.message || /error|threw/.test(String(after.message)), String(after.message)); |
| 404 | await eve.close(); |
| 405 | |
| 406 | const ours = await b.newPage(); |
| 407 | await ours.goto(APP + '/probe', { waitUntil: 'domcontentloaded' }); |
| 408 | await sleep(500); |
| 409 | const mineProbe = await ours.evaluate(() => window.__peek()); |
| 410 | check('the same page on an allowed origin can see the extension, so the probe works', |
| 411 | mineProbe.runtime === 'present' && !!mineProbe.announced, JSON.stringify(mineProbe)); |
| 412 | await ours.close(); |
| 413 | } |
| 414 | |
| 415 | // ── The second look at the boundary is a real one ─────────────── |
| 416 | // |
| 417 | // `mayConnect` used to add each pattern's host with the port stripped off, |
| 418 | // so it would have accepted 8778 on the strength of a pattern naming 8777. |
| 419 | // Chrome honours the port, so nothing was exploitable through it — which is |
| 420 | // the trouble exactly: a re-check laxer than the first is load-bearing only |
| 421 | // on the day the first one changes, and on that day it fails open. |
| 422 | { |
| 423 | const answers = await sw.evaluate(([mine, next]) => ({ |
| 424 | allowed: globalThis.DaimondHand.allowedOrigin({ origin: mine, url: mine + '/' }), |
| 425 | otherPort: globalThis.DaimondHand.allowedOrigin({ origin: next, url: next + '/' }), |
| 426 | otherHost: globalThis.DaimondHand.allowedOrigin({ origin: 'https://daimond.oxedyne.com.evil.test', url: 'https://daimond.oxedyne.com.evil.test/' }), |
| 427 | live: globalThis.DaimondHand.allowedOrigin({ origin: 'https://daimond.oxedyne.com', url: 'https://daimond.oxedyne.com/' }), |
| 428 | nothing: globalThis.DaimondHand.allowedOrigin({}), |
| 429 | }), [APP, HOSTILE]); |
| 430 | check('the relay\'s own check accepts the origins the manifest names', |
| 431 | answers.allowed === APP && answers.live === 'https://daimond.oxedyne.com', JSON.stringify(answers)); |
| 432 | check('the port is part of the origin, so a neighbouring port is refused', |
| 433 | answers.otherPort === '', JSON.stringify(answers.otherPort)); |
| 434 | check('a host that merely starts with ours is refused', |
| 435 | answers.otherHost === '' && answers.nothing === '', JSON.stringify(answers)); |
| 436 | } |
| 437 | |
| 438 | // ── The grant ─────────────────────────────────────────────────── |
| 439 | check('the hand is not granted before it is asked', |
| 440 | !(await sw.evaluate(() => globalThis.DaimondHand.granted()))); |
| 441 | |
| 442 | await page.evaluate(() => window.__open()); |
| 443 | await page.evaluate(() => window.__say({ t: 'hello', proto: 1, client: 'verify_hand' })); |
| 444 | const said = await handWindow(); |
| 445 | const head = said && said.head; |
| 446 | check('opening the port asks the user, in the extension\'s own window', !!head, JSON.stringify(said)); |
| 447 | check('the question is about this computer, not about a site', |
| 448 | /computer/i.test(head || ''), String(head)); |
| 449 | check('the window names the page that asked, because only that page is answered', |
| 450 | !!said && said.host === APP, JSON.stringify(said && said.host)); |
| 451 | |
| 452 | // This hand reports `fence:none`, so the window must NOT promise the |
| 453 | // folders: the sentence is chosen from what the hand said, and this hand |
| 454 | // said it can enforce nothing. |
| 455 | check('the wording is chosen from what the hand said it can enforce', |
| 456 | !!said && /fence:none/.test(said.scope || ''), JSON.stringify(said && said.scope)); |
| 457 | check('and a hand with no fence does not get the sentence about folders', |
| 458 | !!said && !/folders the workspace/.test(said.body || '') |
| 459 | && /cannot limit which files/.test(said.body || ''), JSON.stringify(said && said.body)); |
| 460 | |
| 461 | let all = await until(page, 'hello'); |
| 462 | const hello = all.find((m) => m.t === 'hello'); |
| 463 | check('the hand answers the handshake', !!hello, JSON.stringify(hello)); |
| 464 | check('the answer is the host\'s own, relayed untouched', |
| 465 | !!hello && hello.proto === 1 && /mock/.test(hello.host || ''), JSON.stringify(hello)); |
| 466 | check('the grant is now recorded', |
| 467 | await sw.evaluate(() => globalThis.DaimondHand.granted())); |
| 468 | |
| 469 | check('the grant is recorded for the origin that asked, and only that one', |
| 470 | await sw.evaluate((o) => globalThis.DaimondHand.granted(o), APP) |
| 471 | && !(await sw.evaluate((o) => globalThis.DaimondHand.granted(o), APP2)), |
| 472 | `${APP} yes, ${APP2} no`); |
| 473 | |
| 474 | // ── The grant does not carry to the origin next door ──────────── |
| 475 | // |
| 476 | // The same machine, the same server, the same port, the other spelling of |
| 477 | // loopback. Granted from 127.0.0.1, `localhost` reached the host with no |
| 478 | // window shown at all: the grant was one boolean for the whole browser. |
| 479 | { |
| 480 | const other = await b.newPage(); |
| 481 | await other.goto(APP2 + '/', { waitUntil: 'domcontentloaded' }); |
| 482 | await sleep(700); |
| 483 | await other.evaluate(() => window.__open()); |
| 484 | const asked = await handWindow('deny', 6000); |
| 485 | check('a second origin is asked again rather than inheriting the grant', |
| 486 | !!asked && /computer/i.test(asked.head || ''), JSON.stringify(asked && asked.head)); |
| 487 | check('and the window names the origin that is actually asking', |
| 488 | !!asked && asked.host === APP2, JSON.stringify(asked && asked.host)); |
| 489 | const told = await until(other, 'error', 6000); |
| 490 | check('declining it refuses that origin, in the sentence the daimon reads', |
| 491 | told.some((m) => m.t === 'error' && /declined/i.test(m.message || '')), |
| 492 | JSON.stringify(told.filter((m) => m.t === 'error')).slice(0, 160)); |
| 493 | check('and the first origin still holds its own grant', |
| 494 | await sw.evaluate((o) => globalThis.DaimondHand.granted(o), APP) |
| 495 | && !(await sw.evaluate((o) => globalThis.DaimondHand.granted(o), APP2))); |
| 496 | await other.close(); |
| 497 | } |
| 498 | |
| 499 | // The popup is where a person goes to see what they have allowed and to take |
| 500 | // it back, so the machine hand has to be findable there beside the sites -- |
| 501 | // in words, not as the sentinel the code passes around. |
| 502 | { |
| 503 | const pop = await popup(); |
| 504 | const text = await pop.evaluate(() => document.body.innerText); |
| 505 | check('the popup lists it among what the user has allowed', |
| 506 | /commands on this computer/i.test(text), text.replace(/\n/g, ' / ')); |
| 507 | check('and says which page it was allowed for', text.includes(APP), text.replace(/\n/g, ' / ')); |
| 508 | check('and offers to revoke it', await pop.evaluate(() => { |
| 509 | const li = [...document.querySelectorAll('#granted li')] |
| 510 | .find((n) => /computer/i.test(n.textContent)); |
| 511 | return !!(li && li.querySelector('button')); |
| 512 | })); |
| 513 | await pop.close(); |
| 514 | } |
| 515 | |
| 516 | // ── The wording is the machine's, not the product's ───────────── |
| 517 | // |
| 518 | // Release gate 1 in hand/README.md: the consent window's wording must be |
| 519 | // chosen from `caps` rather than hard-coded, so it can only claim what that |
| 520 | // machine actually enforces. A fenceless machine gets different, honest |
| 521 | // words, and the difference is in the sentence a user would act on. |
| 522 | { |
| 523 | const none = await wordingFor('fence:none'); |
| 524 | const real = await wordingFor('fence:linux landlock:abi-8 journal'); |
| 525 | const silent = await wordingFor(''); |
| 526 | check('a fenceless machine is not described as fencing anything', |
| 527 | !/folders the workspace/.test(none.body) && /cannot limit which files/.test(none.body), none.body); |
| 528 | check('a machine that fences gets the sentence about folders', |
| 529 | /folders the workspace/.test(real.body), real.body); |
| 530 | check('the two are not the same words', none.body !== real.body); |
| 531 | check('a hand that keeps a journal is the only one that promises one', |
| 532 | /journal/i.test(real.fine) && !/journal/i.test(none.fine), `${real.fine} || ${none.fine}`); |
| 533 | check('what the machine can enforce is shown verbatim', |
| 534 | /landlock:abi-8/.test(real.scope) && /fence:none/.test(none.scope), `${real.scope} || ${none.scope}`); |
| 535 | check('a hand that said nothing is a third answer, not a promise', |
| 536 | /did not say/.test(silent.scope) && /did not say/.test(silent.body), `${silent.scope} || ${silent.body}`); |
| 537 | } |
| 538 | |
| 539 | // ── Order and attribution ─────────────────────────────────────── |
| 540 | await page.evaluate(() => window.__exec('r1', ['cargo', 'test'])); |
| 541 | all = await until(page, 'ended'); |
| 542 | const mine = all.filter((m) => m.id === 'r1'); |
| 543 | const chunks = mine.filter((m) => m.t === 'chunk'); |
| 544 | const outs = chunks.filter((m) => m.stream === 'out'); |
| 545 | check('the run starts and ends', mine.some((m) => m.t === 'started') && mine.some((m) => m.t === 'ended')); |
| 546 | check('every chunk arrives as its own message, none joined', chunks.length === 4, `${chunks.length} chunks`); |
| 547 | check('the out stream is in order and complete', |
| 548 | outs.map((m) => m.seq).join(',') === '1,2,3', outs.map((m) => m.seq).join(',')); |
| 549 | check('started comes before the first chunk, ended after the last', |
| 550 | mine.findIndex((m) => m.t === 'started') === 0 && mine[mine.length - 1].t === 'ended', |
| 551 | mine.map((m) => m.t).join(' ')); |
| 552 | check('the chunks carry the text the host sent', |
| 553 | outs.map((m) => m.data).join('').includes('line 2 of cargo test'), ''); |
| 554 | check('no gap is reported when there is none', |
| 555 | !mine.some((m) => m.t === 'error'), JSON.stringify(mine.filter((m) => m.t === 'error'))); |
| 556 | |
| 557 | // ── The page does not choose its own compartment ──────────────── |
| 558 | // |
| 559 | // The fence arrived from the page and went to the hand verbatim, and an |
| 560 | // exec with no fence at all went too. A reviewer sent `fence:{rw:["/"]}` |
| 561 | // with its own LD_PRELOAD and the hand received it byte for byte. The hand |
| 562 | // is the authority and is being made to clamp; these are the shapes this end |
| 563 | // can be sure of, and each of them is refused before the host sees it. |
| 564 | { |
| 565 | // A fresh port, so the refusals are the only things on it. |
| 566 | await page.evaluate(() => { window.__say({ t: 'bye' }); window.__port.disconnect(); }); |
| 567 | await sleep(400); |
| 568 | await page.evaluate(() => window.__open()); |
| 569 | await sleep(300); |
| 570 | |
| 571 | await page.evaluate(() => window.__exec('bad-nofence', ['cargo', 'test'], {}, ['fence'])); |
| 572 | await page.evaluate(() => window.__exec('bad-root', ['cargo', 'test'], |
| 573 | { fence: { rw: ['/'], ro: [], deny: [], net: true } })); |
| 574 | await page.evaluate(() => window.__exec('bad-home', ['cargo', 'test'], |
| 575 | { fence: { rw: ['/home'], ro: [], deny: [], net: false } })); |
| 576 | await page.evaluate(() => window.__exec('bad-env', ['cargo', 'test'], |
| 577 | { env: [['LD_PRELOAD', '/tmp/evil.so']] })); |
| 578 | await page.evaluate(() => window.__exec('bad-cwd', ['cargo', 'test'], |
| 579 | { cwd: '/etc', fence: { rw: ['/tmp'], ro: [], deny: [], net: false } })); |
| 580 | await page.evaluate(() => window.__exec('bad-outside', ['cargo', 'test'], |
| 581 | { cwd: '/var/tmp', fence: { rw: ['/var/tmp'], ro: [], deny: [], net: false } })); |
| 582 | await page.evaluate(() => window.__exec('bad-timeout', ['cargo', 'test'], { timeout_ms: 0 })); |
| 583 | await page.evaluate(() => window.__exec('x'.repeat(400), ['cargo', 'test'])); |
| 584 | await sleep(800); |
| 585 | |
| 586 | const noFence = await refusalFor(page, 'bad-nofence'); |
| 587 | const rootFence = await refusalFor(page, 'bad-root'); |
| 588 | const homeFence = await refusalFor(page, 'bad-home'); |
| 589 | const badEnv = await refusalFor(page, 'bad-env'); |
| 590 | const badCwd = await refusalFor(page, 'bad-cwd'); |
| 591 | const badTime = await refusalFor(page, 'bad-timeout'); |
| 592 | check('an exec with no fence at all is refused', |
| 593 | !!noFence && /fence/i.test(noFence.reason), JSON.stringify(noFence)); |
| 594 | check('a fence naming the whole filesystem is refused', |
| 595 | !!rootFence && /machine/i.test(rootFence.reason), JSON.stringify(rootFence)); |
| 596 | check('and so is one naming a folder the machine follows from', |
| 597 | !!homeFence, JSON.stringify(homeFence)); |
| 598 | check('an LD_PRELOAD in the environment is refused', |
| 599 | !!badEnv && /LD_PRELOAD/.test(badEnv.reason), JSON.stringify(badEnv)); |
| 600 | check('a working directory outside the fence is refused', |
| 601 | !!badCwd && /outside/i.test(badCwd.reason), JSON.stringify(badCwd)); |
| 602 | check('a command with no wall-clock limit is refused', |
| 603 | !!badTime && /timeout_ms/.test(badTime.reason), JSON.stringify(badTime)); |
| 604 | // The hand said `root:/tmp` in its hello, which is the folder its grant |
| 605 | // covers. A fence outside that is a fence the grant does not reach, and |
| 606 | // this end holds the page to it as well. |
| 607 | const outside = await refusalFor(page, 'bad-outside'); |
| 608 | check('a fence outside the folder the hand says it was granted is refused', |
| 609 | !!outside && /outside/.test(outside.reason) && /tmp/.test(outside.reason), |
| 610 | JSON.stringify(outside)); |
| 611 | const seenAll = await seen(page); |
| 612 | const longId = seenAll.find((m) => m.t === 'refused' && m.id.length > 300); |
| 613 | check('an unbounded id is refused, because every answer carries it', |
| 614 | !!longId && /128/.test(longId.reason), JSON.stringify(longId && longId.reason)); |
| 615 | check('none of them reached the host', await (async () => { |
| 616 | const log = fs.existsSync(MOCKLOG) ? fs.readFileSync(MOCKLOG, 'utf8') : ''; |
| 617 | return !/bad-nofence|bad-root|bad-env|LD_PRELOAD/.test(log); |
| 618 | })(), 'the mock host logs everything it is sent'); |
| 619 | |
| 620 | // ── A TERMINAL may be fenced wider than a command ────────────── |
| 621 | // |
| 622 | // The hand offers `terminal-ceiling:` folders in its own hello, and this end lets a |
| 623 | // TERMINAL be fenced to one of them. Both halves are checked, because letting the |
| 624 | // wider folder through for everything would pass the first on its own -- and that is |
| 625 | // the difference between a terminal being the user and the fence being a formality. |
| 626 | // |
| 627 | // `/var/tmp` is the same folder the exec check above is refused for, so the two |
| 628 | // answers are about the same path and differ only in which door asked. |
| 629 | register({ chunks: 1, caps: ['fence:linux', 'root:/tmp', 'terminal-ceiling:/var/tmp'] }); |
| 630 | await page.evaluate(() => window.__open()); |
| 631 | await page.evaluate(() => window.__say({ t: 'hello', proto: 2, client: 'harness' })); |
| 632 | await sleep(300); |
| 633 | await page.evaluate(() => window.__say({ |
| 634 | t: 'open', id: 'term-wide', argv: ['/bin/bash'], cwd: '/var/tmp', |
| 635 | env: [], size: { cols: 80, rows: 24 }, |
| 636 | fence: { rw: ['/var/tmp'], ro: [], deny: [], net: false }, |
| 637 | })); |
| 638 | await page.evaluate(() => window.__say({ |
| 639 | t: 'exec', id: 'cmd-wide', argv: ['/bin/echo', 'hi'], cwd: '/var/tmp', |
| 640 | env: [], timeout_ms: 5000, capture: 'both', |
| 641 | fence: { rw: ['/var/tmp'], ro: [], deny: [], net: false }, |
| 642 | })); |
| 643 | await sleep(600); |
| 644 | const termWide = await refusalFor(page, 'term-wide'); |
| 645 | const cmdWide = await refusalFor(page, 'cmd-wide'); |
| 646 | check('a TERMINAL may be fenced to a folder the machine offered as a ceiling', |
| 647 | !termWide, JSON.stringify(termWide && termWide.reason).slice(0, 160)); |
| 648 | check('and a COMMAND with the same fence is still refused, which is the whole difference', |
| 649 | !!cmdWide && /outside/.test(cmdWide.reason), |
| 650 | JSON.stringify(cmdWide && cmdWide.reason).slice(0, 160)); |
| 651 | |
| 652 | // And a well-formed one still runs, so none of the above is a blanket no. |
| 653 | await page.evaluate(() => window.__exec('good', ['cargo', 'test'])); |
| 654 | const done = await until(page, 'ended'); |
| 655 | check('a well-formed exec is still forwarded and still runs', |
| 656 | done.some((m) => m.t === 'ended' && m.id === 'good'), |
| 657 | JSON.stringify(done.filter((m) => m.t === 'refused')).slice(0, 200)); |
| 658 | } |
| 659 | |
| 660 | // ── A gap is announced, not hidden ────────────────────────────── |
| 661 | register({ chunks: 3, gap: true }); |
| 662 | await page.evaluate(() => { window.__say({ t: 'bye' }); window.__port.disconnect(); }); |
| 663 | await sleep(400); |
| 664 | await page.evaluate(() => window.__open()); |
| 665 | await page.evaluate(() => window.__exec('r2', ['make'])); |
| 666 | all = await until(page, 'ended'); |
| 667 | const gapErr = all.find((m) => m.t === 'error' && /missing|hole|sequence/i.test(m.message || '')); |
| 668 | check('a hole in the sequence is reported', !!gapErr, JSON.stringify(gapErr)); |
| 669 | check('the report names the run and the stream it belongs to', |
| 670 | !!gapErr && gapErr.id === 'r2' && /out/.test(gapErr.message), JSON.stringify(gapErr)); |
| 671 | const iErr = all.indexOf(gapErr); |
| 672 | const iChunk = all.findIndex((m) => m.t === 'chunk' && m.seq === 3); |
| 673 | check('it arrives before the chunk that revealed it', iErr >= 0 && iErr < iChunk, `${iErr} < ${iChunk}`); |
| 674 | check('the chunks are still forwarded, hole and all', |
| 675 | all.filter((m) => m.t === 'chunk' && m.stream === 'out').length === 3); |
| 676 | |
| 677 | // ── Over Chrome's 1 MB cap ────────────────────────────────────── |
| 678 | register({ huge: true }); |
| 679 | await page.evaluate(() => { window.__say({ t: 'bye' }); window.__port.disconnect(); }); |
| 680 | await sleep(400); |
| 681 | await page.evaluate(() => window.__open()); |
| 682 | await page.evaluate(() => window.__exec('r3', ['dump'])); |
| 683 | all = await until(page, 'ended'); |
| 684 | const capErr = all.find((m) => m.t === 'error' && /1 MB|disconnected/i.test(m.message || '')); |
| 685 | check('an oversized message is reported, not swallowed', !!capErr, JSON.stringify(capErr)); |
| 686 | check('the report names the 1 MB limit as a cause', |
| 687 | !!capErr && /1 MB/.test(capErr.message), (capErr || {}).message); |
| 688 | check('the run is closed out so the page is not left waiting', |
| 689 | all.some((m) => m.t === 'ended' && m.id === 'r3' && m.exit === -1), |
| 690 | JSON.stringify(all.filter((m) => m.t === 'ended'))); |
| 691 | |
| 692 | // ── A host that dies mid-command ──────────────────────────────── |
| 693 | register({ crash: true }); |
| 694 | await page.evaluate(() => { window.__say({ t: 'bye' }); window.__port.disconnect(); }); |
| 695 | await sleep(400); |
| 696 | await page.evaluate(() => window.__open()); |
| 697 | await page.evaluate(() => window.__exec('r4', ['boom'])); |
| 698 | all = await until(page, 'ended'); |
| 699 | check('a crash mid-command is reported', |
| 700 | all.some((m) => m.t === 'error' && /disconnected/i.test(m.message || '')), |
| 701 | JSON.stringify(all.filter((m) => m.t === 'error'))); |
| 702 | check('and its run is closed out too', |
| 703 | all.some((m) => m.t === 'ended' && m.id === 'r4' && m.killed === true)); |
| 704 | |
| 705 | // ── The page goes away with a command running ─────────────────── |
| 706 | register({ chunks: 3, delay_ms: 900 }); |
| 707 | const runner = await b.newPage(); |
| 708 | await runner.goto(APP + '/', { waitUntil: 'domcontentloaded' }); |
| 709 | await sleep(500); |
| 710 | await runner.evaluate(() => window.__open()); |
| 711 | await runner.evaluate(() => window.__exec('r5', ['sleep'])); |
| 712 | const running = await until(runner, 'started', 6000); |
| 713 | const pid = (running.find((m) => m.t === 'started') || {}).pid; |
| 714 | check('the run is really a process on this machine', !!pid && fs.existsSync(`/proc/${pid}`), String(pid)); |
| 715 | await runner.close(); |
| 716 | // SINCE 2026-08-25 THERE ARE TWO FACTS HERE AND NOT ONE. A page that vanishes |
| 717 | // is held for the length of the grace, because the commonest way a page |
| 718 | // vanishes is a reload; what it left is stopped only when nothing comes back |
| 719 | // for it. So "no orphan" is still the promise and "at once" is no longer part |
| 720 | // of it, and BOTH halves are asserted -- a file that only waited long enough |
| 721 | // would pass with the grace deleted, and one that only checked the hold would |
| 722 | // pass with the teardown deleted. |
| 723 | await sleep(3000); |
| 724 | check('a page that goes away leaves what it was running alive for the grace', |
| 725 | fs.existsSync(`/proc/${pid}`), `pid ${pid}`); |
| 726 | // The host dies when the hold runs out and its port is closed. Poll rather |
| 727 | // than guess: the mock is mid-sleep and only notices its stdin has gone when |
| 728 | // it comes back up. |
| 729 | let alive = true; |
| 730 | for (let i = 0; i < (HOLD_MS + 20000) / 200 && alive; i++) { |
| 731 | await sleep(200); |
| 732 | alive = fs.existsSync(`/proc/${pid}`); |
| 733 | } |
| 734 | check('and when nothing comes back for it, leaves no orphan behind', !alive, `pid ${pid}`); |
| 735 | |
| 736 | // And it says so rather than merely dropping the pipe. A host that is |
| 737 | // between commands is sitting in a read, which is where a `bye` can actually |
| 738 | // be seen -- the one above was mid-run and was reaped before it looked. |
| 739 | register({ chunks: 1 }); |
| 740 | const idle = await b.newPage(); |
| 741 | await idle.goto(APP + '/', { waitUntil: 'domcontentloaded' }); |
| 742 | await sleep(500); |
| 743 | await idle.evaluate(() => window.__open()); |
| 744 | await idle.evaluate(() => window.__say({ t: 'hello', proto: 1, client: 'verify_hand' })); |
| 745 | await until(idle, 'hello', 6000); |
| 746 | await idle.close(); |
| 747 | let log = ''; |
| 748 | for (let i = 0; i < (HOLD_MS + 20000) / 200; i++) { |
| 749 | await sleep(200); |
| 750 | log = fs.existsSync(MOCKLOG) ? fs.readFileSync(MOCKLOG, 'utf8') : ''; |
| 751 | if (/"bye"/.test(log)) break; |
| 752 | } |
| 753 | check('the relay says bye on the way out, once the hold has run out', /"bye"/.test(log), |
| 754 | log.split('\n').slice(-3).join(' | ')); |
| 755 | |
| 756 | // ── The host is not installed ─────────────────────────────────── |
| 757 | unregister(); |
| 758 | await page.evaluate(() => { try { window.__say({ t: 'bye' }); window.__port.disconnect(); } catch (e) {} }); |
| 759 | await sleep(400); |
| 760 | await page.evaluate(() => window.__open()); |
| 761 | await page.evaluate(() => window.__say({ t: 'hello', proto: 1, client: 'verify_hand' })); |
| 762 | all = await until(page, 'error'); |
| 763 | const gone = all.find((m) => m.t === 'error'); |
| 764 | check('a missing host is reported as such', !!gone, JSON.stringify(gone)); |
| 765 | check('and the sentence says exactly what to install', |
| 766 | !!gone && /install\.sh/.test(gone.message) && /com\.oxedyne\.daimond\.hand/.test(gone.message), |
| 767 | (gone || {}).message); |
| 768 | check('it does not merely repeat Chrome\'s own wording', |
| 769 | !!gone && /cargo build|README/.test(gone.message), ''); |
| 770 | |
| 771 | // ── Revocation ────────────────────────────────────────────────── |
| 772 | // |
| 773 | // Clicked, not messaged. The Revoke button beside the machine hand is the |
| 774 | // one a person would press, and it is a plain button in a plain list. |
| 775 | { |
| 776 | const pop = await popup(); |
| 777 | await pop.evaluate(() => { |
| 778 | const li = [...document.querySelectorAll('#granted li')] |
| 779 | .find((n) => /computer/i.test(n.textContent)); |
| 780 | if (li) li.querySelector('button').click(); |
| 781 | }); |
| 782 | await sleep(700); |
| 783 | check('revoking from the popup takes the grant back', |
| 784 | !(await sw.evaluate(() => globalThis.DaimondHand.granted()))); |
| 785 | const text = await pop.evaluate(() => document.body.innerText); |
| 786 | check('and the popup no longer lists it', !/commands on this computer/i.test(text), |
| 787 | text.replace(/\n/g, ' / ')); |
| 788 | await pop.close(); |
| 789 | } |
| 790 | } finally { |
| 791 | await b.close().catch(() => {}); |
| 792 | for (const s of servers) s.close(); |
| 793 | try { fs.rmSync(MOCKDIR, { recursive: true, force: true }); } catch (e) {} |
| 794 | } |
| 795 | |
| 796 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 797 | process.exit(bad.length ? 1 : 0); |