oxedyne/daimond/dev/verify_crystalpage.mjs
56.4 KiB, 1 run
created by r2519314175:333, 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_crystalpage.mjs — a Diamond's crystal is rendered by a page a MODEL |
| 2 | // wrote, and that page is a stranger in the house. |
| 3 | // |
| 4 | // The page is the first thing in this app that runs code the user never saw, |
| 5 | // written by a model that may itself have been steered by a web page it read a |
| 6 | // moment ago — and unlike every other injection surface here, this one PERSISTS. |
| 7 | // A hostile line in a page is absent from the standing context, unseen by the |
| 8 | // reducer and unseen by the fold diff: the one injection in Daimond that survives |
| 9 | // a turn, and it syncs to every device the user owns. It is NOT exempt from a cap, |
| 10 | // which this list said until 2026-08-28 -- `dev/verify_crystalcap.mjs` enforces |
| 11 | // `CRYSTAL_PAGE_CAP_DEFAULT` on it and has since 2026-08-09. The clause was never |
| 12 | // carrying the argument: a line survives because nothing here reads the page, not |
| 13 | // because nothing weighs it. |
| 14 | // |
| 15 | // TWO mechanisms hold it, and they close different halves of the problem. |
| 16 | // |
| 17 | // The SANDBOX stops the page READING. `allow-scripts` and nothing else, which is |
| 18 | // easy to lose: the page is served from a `blob:` URL, and a blob URL INHERITS |
| 19 | // OUR ORIGIN. Add `allow-same-origin` and the page is not sandboxed at all — it |
| 20 | // runs as Daimond, reads the `localStorage` the account key lives in, and reaches |
| 21 | // OPFS. Nothing about the screen would look different. |
| 22 | // |
| 23 | // The CONTENT SECURITY POLICY stops the page SENDING, and the sandbox does |
| 24 | // nothing about that. The page is handed the whole crystal by design, so without |
| 25 | // a policy a model-authored page can beacon it to a third party on every render, |
| 26 | // on every device, silently, with no egress gate anywhere near it. That is the |
| 27 | // same shape of risk that removed the `ask` verb, and `mount` answers it by |
| 28 | // injecting `default-src 'none'` into any page that did not bring its own. |
| 29 | // |
| 30 | // What is pinned here, in the order of how much damage its absence does: |
| 31 | // |
| 32 | // 1. The frame is sandboxed, and the sandbox is REAL — asserted by the parent |
| 33 | // being unable to reach into the frame, not only by reading an attribute. |
| 34 | // 2. The page cannot reach the network. Asserted by the page TRYING, from |
| 35 | // inside, and reporting that it was stopped. |
| 36 | // 3. There is no `ask` verb and no write verb. The channel is pull-only, and a |
| 37 | // message claiming otherwise changes nothing. A parent cannot verify user |
| 38 | // activation across the boundary, so a timer is indistinguishable from a |
| 39 | // click; that is why `ask()` was dropped and why the ask-the-daimon box |
| 40 | // lives in app chrome BELOW the frame, where a click is provably a person. |
| 41 | // 4. A message that is not from the frame's own `contentWindow`, or does not |
| 42 | // carry `{dc:1, v:1}`, is ignored. Any script on the page can postMessage. |
| 43 | // 5. `asset` reads this Diamond's own files and nothing else. |
| 44 | // 6. A page that never answers falls back to the built-in view, visibly. This |
| 45 | // is what a syntax error looks like from outside an opaque origin: there is |
| 46 | // no error to see, only silence. So does a page that answers and reports |
| 47 | // rendered keys NOT covering the data's content-carrying keys — the case a |
| 48 | // naive test misses, and the one that catches a page rendering three |
| 49 | // sections of seven after a key rename. So does a page that says `ready` and |
| 50 | // never reports at all, and a page that navigates itself somewhere else. |
| 51 | // (The Claude Artifacts lesson: the sandbox is not the hard part; everything |
| 52 | // it forbids fails silently.) |
| 53 | // 7. Nothing is dropped. An unknown top-level key survives the built-in view |
| 54 | // and a round trip through the form editor. Home Assistant is the precedent |
| 55 | // — Lovelace's structured editor silently deletes `card_mod` config it |
| 56 | // cannot express, and an LLM is not more careful than a form editor. |
| 57 | // 8. A link goes through the egress gate, and the gate is asked AGAIN for the |
| 58 | // same host, because one reasonable approval must not let the page walk the |
| 59 | // crystal out in URL fragments. A link to Daimond's own address never gets |
| 60 | // that far: `egressAllowed` answers `allow` for our own host before any |
| 61 | // field can influence it, so `crystal.js` refuses it itself. |
| 62 | // 9. The editor refuses unparseable JSON and says so. Today `editCrystal` |
| 63 | // writes a textarea straight through — harmless for markdown, which has no |
| 64 | // parse failure, and a data-loss path for JSON. |
| 65 | // |
| 66 | // THE INSTRUMENT, and why it is trustworthy. Most of these are assertions that |
| 67 | // NOTHING happened, and a probe that never fired satisfies every one of them. So |
| 68 | // the harness supplies each Diamond's page itself: a page that relays whatever |
| 69 | // the harness asks it to say, so a message under test genuinely originates in the |
| 70 | // frame's own `contentWindow` — nothing else can produce one. It also reports on |
| 71 | // its own document from the inside, which is the only place the injected policy |
| 72 | // and the resulting quirks mode can honestly be read. The egress check runs FIRST |
| 73 | // and proves the relay reaches the app; every "nothing happened" check below it |
| 74 | // is therefore about the app's answer and not about a dead probe. |
| 75 | // |
| 76 | // `DaimondCrystal._state()` exists for this file. It is used for the machine |
| 77 | // half of each fallback — which of the two reasons fired — and never on its own: |
| 78 | // a module agreeing with itself proves nothing about what a person sees, so every |
| 79 | // fallback is also read off the screen. |
| 80 | // |
| 81 | // How these go red (this lane could not run a browser, so the lead's batched pass |
| 82 | // is the first time they are exercised): |
| 83 | // |
| 84 | // * add `allow-same-origin` to the sandbox → checks 1a and 1c go red, and 1c |
| 85 | // stays red even if the attribute is later split across two `setAttribute` |
| 86 | // calls that a string comparison would miss; |
| 87 | // * skip the policy injection → the beacon check goes red and every other |
| 88 | // check in the file stays green, which is exactly why it is here; |
| 89 | // * put the injected `<meta>` ahead of the doctype → the quirks-mode check goes |
| 90 | // red and the policy check stays green; |
| 91 | // * accept a message without comparing `e.source` → the three source checks go |
| 92 | // red and nothing else does; |
| 93 | // * drop the coverage half of the fallback rule → the partial check goes red |
| 94 | // and the timeout check stays green, which is the shape of the naive build; |
| 95 | // * make the coverage rule "every key" rather than "every key that carries |
| 96 | // content" → the sparse control goes red; |
| 97 | // * memoise the egress answer per host → the second-time check goes red. |
| 98 | // |
| 99 | // node dev/verify_crystalpage.mjs |
| 100 | // |
| 101 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway, no mock LLM: nothing |
| 102 | // here runs a turn. |
| 103 | import fs from 'node:fs'; |
| 104 | import { open, scratch, signInAs, APP } from './harness.mjs'; |
| 105 | |
| 106 | const PROFILE = scratch('pw', 'crystalpage'); |
| 107 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 108 | |
| 109 | let bad = 0; |
| 110 | const check = (pass, name, detail) => { |
| 111 | if (!pass) bad++; |
| 112 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 113 | }; |
| 114 | |
| 115 | /// Apostrophes and runs of whitespace, flattened. |
| 116 | /// |
| 117 | /// A string compared against what is on screen must survive the difference |
| 118 | /// between a typewriter apostrophe and a typographic one, and between a newline |
| 119 | /// in the source and a space in `innerText`. Neither difference is the property. |
| 120 | const norm = (x) => String(x == null ? '' : x) |
| 121 | .replace(/[‘’ʼ´]/g, "'") |
| 122 | .replace(/\s+/g, ' ') |
| 123 | .trim(); |
| 124 | |
| 125 | /// The policy `mount` injects into a page that arrived without one. |
| 126 | /// |
| 127 | /// READ FROM THE APP, not copied. A literal here goes green for ever the moment the |
| 128 | /// policy is tightened or loosened, which is the one thing a check on a security |
| 129 | /// property must not do -- it would have said "the page is given a policy" about |
| 130 | /// whatever string happened to be there. `DaimondCrystal.PAGE_CSP` is exported for |
| 131 | /// exactly this. |
| 132 | /// |
| 133 | /// Reading it from the app makes THIS check self-consistent, so it cannot be the |
| 134 | /// only one: `admitsNoHost` below asserts the property the policy exists for, and |
| 135 | /// the beacon check asserts the consequence by trying the network from inside the |
| 136 | /// frame and being stopped. Those two are the evidence; this one only says the |
| 137 | /// page was given the app's policy rather than some other. |
| 138 | let POLICY = null; |
| 139 | |
| 140 | /// Whether a policy admits no network origin at all. |
| 141 | /// |
| 142 | /// The only sources allowed to appear are the ones that cannot reach a host: |
| 143 | /// `'none'`, `'self'` is NOT among them, `'unsafe-inline'`, and `data:`. Anything |
| 144 | /// naming a scheme, a wildcard or a domain is a way out, and the whole point of the |
| 145 | /// policy is that a page handed the crystal has none. |
| 146 | const admitsNoHost = (policy) => String(policy) |
| 147 | .split(';') |
| 148 | .map((d) => d.trim()) |
| 149 | .filter(Boolean) |
| 150 | .every((d) => d.split(/\s+/).slice(1) |
| 151 | .every((src) => /^('none'|'unsafe-inline'|data:)$/.test(src))); |
| 152 | /// A page that brought its own. Deliberately short of `style-src`, so "the page |
| 153 | /// kept its own" and "the app added its own as well" are different strings. |
| 154 | const OWN_POLICY = "default-src 'none'; script-src 'unsafe-inline'; img-src data:"; |
| 155 | /// Somewhere that certainly answers, so a blocked beacon means the policy and not |
| 156 | /// a dead host. `no-cors`, so CORS cannot be mistaken for the thing under test. |
| 157 | const BEACON = APP + '/index.html'; |
| 158 | |
| 159 | // ── The probe pages ────────────────────────────────────────────────── |
| 160 | // |
| 161 | // Each is a real page written into a real Diamond's `crystal.html`, so the whole |
| 162 | // path runs: the store reads it, the app mounts it, the channel carries it. |
| 163 | |
| 164 | /// A page that speaks the protocol and relays anything the harness asks it to say. |
| 165 | /// |
| 166 | /// The relay is the only way to produce a message whose `source` really is this |
| 167 | /// frame's `contentWindow`. The echo goes back out under a verb the parent does |
| 168 | /// not know, so the harness can watch the far side of the channel — including the |
| 169 | /// parent's own answers — without changing what the app does with them. |
| 170 | /// |
| 171 | /// # Arguments |
| 172 | /// * `o.report` - Source of a function returning the keys to report as rendered; |
| 173 | /// omit it entirely for a page that says `ready` and then nothing. |
| 174 | /// * `o.csp` - A policy the page brings itself, for the case where `mount` must |
| 175 | /// leave it alone. |
| 176 | const probePage = (o) => { |
| 177 | o = o || {}; |
| 178 | const own = o.csp |
| 179 | ? '<meta http-equiv="Content-Security-Policy" content="' + o.csp + '">' |
| 180 | : ''; |
| 181 | return [ |
| 182 | '<!doctype html><meta charset="utf-8"><title>probe</title>' + own, |
| 183 | '<body><div id="out">probe page</div>', |
| 184 | '<script>', |
| 185 | '(function () {', |
| 186 | ' var send = function (m) { parent.postMessage(m, "*"); };', |
| 187 | ' addEventListener("message", function (e) {', |
| 188 | ' var d = e.data;', |
| 189 | ' if (!d || typeof d !== "object") return;', |
| 190 | ' if (d.__say) { send(d.__say); return; }', |
| 191 | ' if (d.__nav) { location.href = "about:blank"; return; }', |
| 192 | ' if (d.__self) {', |
| 193 | ' var ms = [].slice.call(document.querySelectorAll(', |
| 194 | ' "meta[http-equiv=\'Content-Security-Policy\']"));', |
| 195 | ' var out = {', |
| 196 | ' compat: document.compatMode,', |
| 197 | ' doctype: !!document.doctype,', |
| 198 | ' policies: ms.map(function (m) { return m.getAttribute("content") || ""; }),', |
| 199 | ' };', |
| 200 | ' var done = function (b) {', |
| 201 | ' out.beacon = b;', |
| 202 | ' send({ dc: 1, v: 1, cmd: "__probe_echo", self: out });', |
| 203 | ' };', |
| 204 | ' try {', |
| 205 | ' fetch("' + BEACON + '", { mode: "no-cors" })', |
| 206 | ' .then(function () { done("allowed"); }, function () { done("blocked"); });', |
| 207 | ' } catch (err) { done("blocked"); }', |
| 208 | ' return;', |
| 209 | ' }', |
| 210 | ' send({ dc: 1, v: 1, cmd: "__probe_echo", said: d });', |
| 211 | ' if (d.dc === 1 && d.cmd === "data") {', |
| 212 | ' var data = d.data || {};', |
| 213 | ' var keys = Object.keys(data);', |
| 214 | ' document.getElementById("out").textContent = keys.join(", ");', |
| 215 | (o.report |
| 216 | ? ' send({ dc: 1, v: 1, cmd: "rendered", keys: (' + o.report + ')(data, keys) });' |
| 217 | : ' /* deliberately silent: ready, and then nothing at all */'), |
| 218 | ' }', |
| 219 | ' });', |
| 220 | ' send({ dc: 1, v: 1, cmd: "ready" });', |
| 221 | '}());', |
| 222 | '<\/script>', |
| 223 | ].join('\n'); |
| 224 | }; |
| 225 | |
| 226 | /// Every key it was given. |
| 227 | const REPORT_ALL = 'function (d, k) { return k; }'; |
| 228 | /// The title and nothing else: a page that renders one section of several. |
| 229 | const REPORT_TITLE = 'function (d, k) { return k.filter(function (x) { return x === "title"; }); }'; |
| 230 | /// Every key that carries content, and only those. |
| 231 | /// |
| 232 | /// A key holding an empty string or an empty list has nothing to render, so a |
| 233 | /// page that skips it has skipped nothing, and a parent that fell back over it |
| 234 | /// would fall back on most Diamonds. Nor does this page report the channel's own |
| 235 | /// keys, the ones with a leading underscore: they are not the Diamond's data and |
| 236 | /// a page is not failing by leaving them out. |
| 237 | const REPORT_CARRYING = 'function (d, k) { return k.filter(function (x) {' |
| 238 | + ' var v = d[x];' |
| 239 | + ' if (x.charAt(0) === "_") return false;' |
| 240 | + ' if (v === "" || v === null || v === undefined) return false;' |
| 241 | + ' if (Array.isArray(v) && !v.length) return false;' |
| 242 | + ' return true; }); }'; |
| 243 | |
| 244 | /// A page that cannot be parsed, which is the failure the parent CANNOT see: no |
| 245 | /// error crosses an opaque origin, so silence is the only symptom. |
| 246 | const PAGE_BROKEN = '<!doctype html><meta charset="utf-8"><title>broken</title>' |
| 247 | + '<body>a page that will not parse<script>this is not ) valid javascript(<\/script>'; |
| 248 | |
| 249 | /// A real, protocol-speaking page with a solid background it chose for itself -- |
| 250 | /// what a page looks like after a person asked the daimon for one, or hand-edited |
| 251 | /// `crystal.html` directly. Content is deliberately ONE short line, so the page's |
| 252 | /// own measured height is far short of the panel: the property under test is |
| 253 | /// whether the REST of the panel picks up this colour or the app's ordinary |
| 254 | /// chrome shows through beneath it. |
| 255 | const SOLID_BG = '#ff00aa'; |
| 256 | const PAGE_SOLID_BG = [ |
| 257 | '<!doctype html><meta charset="utf-8"><title>solid</title>', |
| 258 | '<style>html,body{background:' + SOLID_BG + ';margin:0;padding:0}', |
| 259 | 'body{font-family:sans-serif;color:#fff;padding:8px}</style>', |
| 260 | '<body><div id="r"></div><script>', |
| 261 | '(function(){', |
| 262 | 'var R=document.getElementById("r"),last=-1;', |
| 263 | 'function post(o){o.dc=1;o.v=1;parent.postMessage(o,"*");}', |
| 264 | 'function measure(){var px=Math.ceil(Math.max(document.body.scrollHeight,', |
| 265 | 'R.getBoundingClientRect().height))+2;', |
| 266 | 'if(Math.abs(px-last)<2)return;last=px;post({cmd:"height",px:px});}', |
| 267 | 'addEventListener("message",function(e){if(e.source!==parent)return;', |
| 268 | 'var m=e.data;if(!m||m.dc!==1||m.v!==1)return;', |
| 269 | 'if(m.cmd==="data"){var d=m.data||{};', |
| 270 | 'var h="<h1>"+(d.title||"")+"</h1><p>"+(d.summary||"")+"</p>";', |
| 271 | // `lines`, rendered one `<p>` per entry: how the TALL fixture below gets |
| 272 | // genuine, measurable content height rather than a hard-coded number this |
| 273 | // page would have to be trusted to report honestly. |
| 274 | '(d.lines||[]).forEach(function(l){h+="<p>"+l+"</p>";});', |
| 275 | 'R.innerHTML=h;', |
| 276 | 'post({cmd:"rendered",keys:Object.keys(d)});measure();}});', |
| 277 | 'if(window.ResizeObserver)new ResizeObserver(measure).observe(document.body);', |
| 278 | 'post({cmd:"ready"});', |
| 279 | '})();', |
| 280 | '<\/script>', |
| 281 | ].join('\n'); |
| 282 | |
| 283 | // ── The fixtures ───────────────────────────────────────────────────── |
| 284 | const D = { |
| 285 | good: { name: 'Page Good', page: probePage({ report: REPORT_ALL }) }, |
| 286 | broken: { name: 'Page Broken', page: PAGE_BROKEN }, |
| 287 | partial: { name: 'Page Partial', page: probePage({ report: REPORT_TITLE }) }, |
| 288 | silent: { name: 'Page Silent', page: probePage({}) }, |
| 289 | sparse: { name: 'Page Sparse', page: probePage({ report: REPORT_CARRYING, csp: OWN_POLICY }) }, |
| 290 | renav: { name: 'Page Renav', page: probePage({ report: REPORT_ALL }) }, |
| 291 | extra: { name: 'Page Extra', page: PAGE_BROKEN }, |
| 292 | bgshort: { name: 'Page BgShort', page: PAGE_SOLID_BG }, |
| 293 | bgtall: { name: 'Page BgTall', page: PAGE_SOLID_BG }, |
| 294 | }; |
| 295 | D.good.data = { |
| 296 | title: 'The good page', |
| 297 | summary: 'Everything here is rendered.', |
| 298 | sections: [{ heading: 'One', body: 'The first thing.' }], |
| 299 | facts: [{ k: 'state', v: 'open' }], |
| 300 | }; |
| 301 | D.broken.data = { title: 'BROKEN-PAGE-TITLE', summary: 'The data must still be readable.' }; |
| 302 | D.partial.data = { |
| 303 | title: 'PARTIAL-PAGE-TITLE', |
| 304 | summary: 'Shown by nothing.', |
| 305 | sections: [{ heading: 'Unrendered', body: 'A section the page never drew.' }], |
| 306 | }; |
| 307 | D.silent.data = { title: 'SILENT-PAGE-TITLE', summary: 'The page said hello and nothing else.' }; |
| 308 | // Two keys that carry nothing. A page that skips them has skipped nothing. |
| 309 | D.sparse.data = { title: 'SPARSE-PAGE-TITLE', summary: 'All of it, drawn.', facts: [], open: [] }; |
| 310 | D.renav.data = { title: 'RENAV-PAGE-TITLE', summary: 'Then it went somewhere else.' }; |
| 311 | // One short line, deliberately: the whole point of this fixture is that its |
| 312 | // measured content height is a fraction of the panel's. |
| 313 | D.bgshort.data = { title: 'BgShort', summary: 'One short line.' }; |
| 314 | // The other half of the same fix, on the same page format: content that is |
| 315 | // genuinely tall, measured by the page itself rather than asserted here. |
| 316 | D.bgtall.data = { |
| 317 | title: 'BgTall', summary: 'Overflow probe.', |
| 318 | lines: Array.from({ length: 150 }, (_, i) => 'Line ' + i + ' of a very long crystal page.'), |
| 319 | }; |
| 320 | // The unknown key is an OBJECT, not a string: a form that keeps unknown keys by |
| 321 | // stringifying them passes the easy version of this check and mangles the real |
| 322 | // one. And it does NOT begin with an underscore — those belong to the channel |
| 323 | // itself (`_theme`, `_labels`), and are stripped by design rather than dropped. |
| 324 | D.extra.data = { |
| 325 | title: 'Extra keys', |
| 326 | summary: 'Holds a key nothing in this app knows about.', |
| 327 | provenance: { from: 'PROVENANCE-KEEPSAKE', when: 7, tags: ['a', 'b'] }, |
| 328 | }; |
| 329 | |
| 330 | // The English the contract fixes for each string, used when the key is not in the |
| 331 | // table yet. `tOr('key', 'English')` renders exactly this, so the comparison holds |
| 332 | // whether or not the i18n lane has landed. |
| 333 | const EN = { |
| 334 | 'crystal.page_failed': 'This Diamond\'s page did not load, so its data is shown instead.', |
| 335 | 'crystal.page_partial': 'This Diamond\'s page did not show everything it holds, so its data is shown instead.', |
| 336 | 'crystal.page_reset': 'Reset the page', |
| 337 | 'crystal.edit_json': 'Edit as JSON', |
| 338 | 'crystal.json_invalid': 'That is not valid JSON, so nothing was saved.', |
| 339 | 'crystal.other_fields': 'Other fields', |
| 340 | 'crystal.field_title': 'Title', |
| 341 | 'common.save': 'Save', |
| 342 | }; |
| 343 | |
| 344 | const s = await open({ name: 'crystalpage', profile: PROFILE, connect: false }); |
| 345 | const p = s.page; |
| 346 | |
| 347 | /// The app's own words for a key, falling back to the contract's English when the |
| 348 | /// string has not landed. `t()` returns the KEY itself when it finds nothing, and |
| 349 | /// a check comparing against a raw key name would be comparing against noise. |
| 350 | const T = async (key) => { |
| 351 | const got = await p.evaluate((k) => { |
| 352 | try { return window.DaimondI18n ? DaimondI18n.t(k) : k; } catch (e) { return k; } |
| 353 | }, key); |
| 354 | return (got === key) ? EN[key] : got; |
| 355 | }; |
| 356 | |
| 357 | /// Open a Diamond by name and put its crystal face up. |
| 358 | /// |
| 359 | /// Reports rather than throws: one fixture missing from the rail must cost the |
| 360 | /// checks that need it, not the whole run. |
| 361 | const showDiamond = async (name) => { |
| 362 | const r = await p.evaluate((nm) => { |
| 363 | const box = [...document.querySelectorAll('#diamond-list .diamond-box')] |
| 364 | .find((b) => ((b.querySelector('.session-box-name') || {}).textContent || '').trim() === nm); |
| 365 | if (!box) { |
| 366 | return { ok: false, saw: [...document.querySelectorAll('#diamond-list .session-box-name')] |
| 367 | .map((n) => (n.textContent || '').trim()).join(' | ') }; |
| 368 | } |
| 369 | box.click(); |
| 370 | return { ok: true, saw: '' }; |
| 371 | }, name); |
| 372 | check(r.ok, 'the Diamond "' + name + '" is on the rail', r.ok ? '' : 'rail holds: ' + r.saw); |
| 373 | await p.waitForTimeout(700); |
| 374 | await p.evaluate(() => { |
| 375 | const b = document.getElementById('dview-crystal'); |
| 376 | if (b && b.getAttribute('aria-pressed') !== 'true') b.click(); |
| 377 | }); |
| 378 | await p.waitForTimeout(300); |
| 379 | return r.ok; |
| 380 | }; |
| 381 | |
| 382 | /// Say something to the parent AS THE FRAME. Throws if there is no frame, so a |
| 383 | /// probe can never quietly measure nothing. |
| 384 | const say = (msg) => p.evaluate((m) => { |
| 385 | const f = document.querySelector('.crystal-frame'); |
| 386 | if (!f || !f.contentWindow) throw new Error('there is no crystal frame to speak from'); |
| 387 | f.contentWindow.postMessage({ __say: m }, '*'); |
| 388 | return true; |
| 389 | }, msg); |
| 390 | |
| 391 | /// Ask the page about its own document, from the inside. |
| 392 | const selfReport = async () => { |
| 393 | await p.evaluate(() => { |
| 394 | const f = document.querySelector('.crystal-frame'); |
| 395 | if (!f || !f.contentWindow) throw new Error('there is no crystal frame to ask'); |
| 396 | f.contentWindow.postMessage({ __self: 1 }, '*'); |
| 397 | }); |
| 398 | await p.waitForTimeout(2000); // the beacon has to be given time to fail |
| 399 | const rows = await p.evaluate(() => window.__seen.slice()); |
| 400 | const hit = rows.filter((x) => x.data && x.data.self).pop(); |
| 401 | return hit ? hit.data.self : null; |
| 402 | }; |
| 403 | |
| 404 | /// Wait for a dialog, answer it, and report what it said. `null` if none came. |
| 405 | const answerDialog = async (yes, ms = 3500) => { |
| 406 | const t0 = Date.now(); |
| 407 | while (Date.now() - t0 < ms) { |
| 408 | const seen = await p.evaluate(() => { |
| 409 | const d = document.querySelector('.dlg .dlg-card'); |
| 410 | return d ? { text: (d.innerText || d.textContent || '').slice(0, 400) } : null; |
| 411 | }); |
| 412 | if (seen) { |
| 413 | await p.evaluate((ok) => { |
| 414 | const d = document.querySelector('.dlg .dlg-card'); |
| 415 | if (!d) return; |
| 416 | const btn = ok ? d.querySelector('.dlg-ok') |
| 417 | : (d.querySelector('.dlg-cancel') || d.querySelector('.dlg-ok')); |
| 418 | if (btn) btn.click(); |
| 419 | }, yes); |
| 420 | await p.waitForTimeout(250); |
| 421 | return seen; |
| 422 | } |
| 423 | await p.waitForTimeout(100); |
| 424 | } |
| 425 | return null; |
| 426 | }; |
| 427 | |
| 428 | /// What is in the crystal panel now, as a person would find it. |
| 429 | const face = () => p.evaluate(() => { |
| 430 | const wrap = document.getElementById('crystal-frame-wrap'); |
| 431 | const frame = document.querySelector('.crystal-frame'); |
| 432 | const fb = document.querySelector('.crystal-fallback'); |
| 433 | const note = document.querySelector('.crystal-fallback-note'); |
| 434 | const reset = document.querySelector('.crystal-reset'); |
| 435 | const body = document.getElementById('crystal-body'); |
| 436 | const seen = (e) => !!e && !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length); |
| 437 | let st = null; |
| 438 | try { st = window.DaimondCrystal ? DaimondCrystal._state() : null; } catch (e) { st = null; } |
| 439 | return { |
| 440 | hasWrap: !!wrap, |
| 441 | hasFrame: !!frame, |
| 442 | framed: seen(frame), |
| 443 | inWrap: !!(frame && wrap && wrap.contains(frame)), |
| 444 | sandbox: frame ? frame.getAttribute('sandbox') : null, |
| 445 | src: frame ? String(frame.getAttribute('src') || '') : null, |
| 446 | reachable: frame ? (frame.contentDocument !== null) : null, |
| 447 | fallback: seen(fb), |
| 448 | note: note ? (note.innerText || note.textContent || '') : null, |
| 449 | reset: reset ? (reset.innerText || reset.textContent || '') : null, |
| 450 | text: body ? (body.innerText || '') : '', |
| 451 | mode: st ? String(st.mode) : null, |
| 452 | ready: st ? !!st.ready : null, |
| 453 | reason: st ? (st.reason || '') : null, |
| 454 | keys: st ? (st.keys || []) : null, |
| 455 | }; |
| 456 | }); |
| 457 | |
| 458 | /// The crystal data as it is on disk, read through the store rather than through |
| 459 | /// whatever the panel believes it is showing. |
| 460 | const stored = (id) => p.evaluate(async (x) => { |
| 461 | try { return await window.__probe.app.read_crystal_data(x); } |
| 462 | catch (e) { return 'ERR ' + (e && e.message || e); } |
| 463 | }, id); |
| 464 | |
| 465 | const ids = {}; |
| 466 | |
| 467 | try { |
| 468 | await p.waitForTimeout(1500); |
| 469 | |
| 470 | // ── Build the fixtures through the store ───────────────────── |
| 471 | const built = await p.evaluate(async (fix) => { |
| 472 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 473 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 474 | const absent = ['write_crystal_data', 'write_crystal_page', 'read_crystal_data'] |
| 475 | .filter((fn) => typeof app[fn] !== 'function'); |
| 476 | if (absent.length) return { absent }; |
| 477 | const out = {}; |
| 478 | for (const key of Object.keys(fix)) { |
| 479 | const id = await app.create_diamond(fix[key].name); |
| 480 | await app.write_crystal_data(id, JSON.stringify(fix[key].data)); |
| 481 | await app.write_crystal_page(id, fix[key].page); |
| 482 | out[key] = id; |
| 483 | } |
| 484 | // A file of this Diamond's own, for the `asset` verb to find. |
| 485 | await app.run_tool('file_write', JSON.stringify({ |
| 486 | path: 'diamonds/' + out.good + '/note.md', content: 'ASSET-IN-SCOPE\n' })); |
| 487 | return { absent: [], ids: out }; |
| 488 | }, D); |
| 489 | |
| 490 | check(built.absent.length === 0, 'the engine can store a Diamond\'s data and its page', |
| 491 | built.absent.length ? 'missing: ' + built.absent.join(', ') : ''); |
| 492 | Object.assign(ids, built.ids || {}); |
| 493 | |
| 494 | // The rail is redrawn from disk the way a person would see it. |
| 495 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 496 | await signInAs(s, 'crystalpage'); |
| 497 | await p.waitForFunction(() => !!window.DaimondCrystal && !!window.__daimondEgressAllowed, |
| 498 | null, { timeout: 20000 }).catch(() => {}); |
| 499 | await p.waitForTimeout(1500); |
| 500 | |
| 501 | POLICY = await p.evaluate(() => (window.DaimondCrystal || {}).PAGE_CSP || ''); |
| 502 | check(!!POLICY, 'the app publishes the policy it injects, so this file need not keep a copy', |
| 503 | 'DaimondCrystal.PAGE_CSP was ' + JSON.stringify(POLICY)); |
| 504 | // The property, asserted independently of what the string happens to say. A |
| 505 | // policy that admitted one host would still be "a policy", and the page is |
| 506 | // handed the whole crystal. |
| 507 | check(admitsNoHost(POLICY), 'and that policy admits no network origin anywhere in it', |
| 508 | POLICY); |
| 509 | |
| 510 | const hasModule = await p.evaluate(() => !!(window.DaimondCrystal |
| 511 | && typeof DaimondCrystal.mount === 'function' |
| 512 | && typeof DaimondCrystal.parse === 'function' |
| 513 | && typeof DaimondCrystal._state === 'function')); |
| 514 | check(hasModule, 'the crystal module is loaded, with the verifier hook', |
| 515 | hasModule ? '' : 'window.DaimondCrystal is not there, or has no _state()'); |
| 516 | |
| 517 | const FALLBACK_MS = await p.evaluate(() => |
| 518 | (window.DaimondCrystal && DaimondCrystal.FALLBACK_MS) || 1500); |
| 519 | // Long enough that a slow machine is not mistaken for a silent page. A page |
| 520 | // that says `ready` and then nothing is given TWO windows, because that is the |
| 521 | // rule: the second one starts when the first is answered. |
| 522 | const settle = FALLBACK_MS + 2500; |
| 523 | const settle2 = FALLBACK_MS * 2 + 3000; |
| 524 | |
| 525 | // The instruments. Reinstalled after the reload, because the reload took the |
| 526 | // previous window with it — `__probe` included, which is why the store handle |
| 527 | // is rebuilt here rather than reused from the fixture build above. |
| 528 | await p.evaluate(async () => { |
| 529 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 530 | window.__probe = { |
| 531 | mod, |
| 532 | app: new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true), |
| 533 | }; |
| 534 | window.__gate = []; |
| 535 | const realGate = window.__daimondEgressAllowed; |
| 536 | window.__daimondEgressAllowed = function (payload) { |
| 537 | window.__gate.push(String(payload)); |
| 538 | return realGate ? realGate.apply(this, arguments) : Promise.resolve('deny'); |
| 539 | }; |
| 540 | // A refused link must not be opened anyway. Stubbed rather than watched, |
| 541 | // because a real tab in a headless run is a hang, not a failure. |
| 542 | window.__opened = []; |
| 543 | window.open = function (u) { window.__opened.push(String(u)); return null; }; |
| 544 | // Everything the frame says, and whether it really came from the frame. |
| 545 | window.__seen = []; |
| 546 | window.addEventListener('message', function (e) { |
| 547 | let mine = false; |
| 548 | try { |
| 549 | const f = document.querySelector('.crystal-frame'); |
| 550 | mine = !!(f && e.source === f.contentWindow); |
| 551 | } catch (x) { /* an opaque origin refuses the comparison */ } |
| 552 | try { window.__seen.push({ mine, data: JSON.parse(JSON.stringify(e.data)) }); } |
| 553 | catch (x) { window.__seen.push({ mine, data: null }); } |
| 554 | }); |
| 555 | }); |
| 556 | const resetProbes = () => p.evaluate(() => { |
| 557 | window.__gate = []; window.__opened = []; window.__seen = []; |
| 558 | return true; |
| 559 | }); |
| 560 | const probes = () => p.evaluate(() => ({ |
| 561 | gate: window.__gate.slice(), |
| 562 | opened: window.__opened.slice(), |
| 563 | seen: window.__seen.slice(), |
| 564 | })); |
| 565 | |
| 566 | // ══ 1. The frame, and the sandbox ═══════════════════════════ |
| 567 | await resetProbes(); |
| 568 | await showDiamond(D.good.name); |
| 569 | await p.waitForTimeout(settle); |
| 570 | let f = await face(); |
| 571 | |
| 572 | check(f.hasFrame && f.inWrap && f.framed, |
| 573 | 'a Diamond with a page shows it in a frame inside #crystal-frame-wrap', |
| 574 | JSON.stringify({ frame: f.hasFrame, wrap: f.hasWrap, inWrap: f.inWrap, shown: f.framed })); |
| 575 | // 1a. The attribute in full, so ADDING a token fails the check. A test for the |
| 576 | // absence of `allow-same-origin` alone would wave through `allow-popups` and |
| 577 | // `allow-top-navigation`, either of which lets the page act on the user's |
| 578 | // behalf somewhere else. |
| 579 | check(norm(f.sandbox) === 'allow-scripts', |
| 580 | 'the frame is sandboxed with allow-scripts and NOTHING else', |
| 581 | JSON.stringify(f.sandbox)); |
| 582 | // 1b. And named, so a failure says which token arrived rather than printing |
| 583 | // two strings and leaving the reader to diff them. |
| 584 | const forbidden = String(f.sandbox || '').split(/\s+/).filter(Boolean) |
| 585 | .filter((x) => x !== 'allow-scripts'); |
| 586 | check(forbidden.length === 0, 'no other sandbox token was granted', forbidden.join(', ')); |
| 587 | // 1c. THE PROPERTY, not the attribute. A frame in an opaque origin refuses the |
| 588 | // parent's reach; a blob-URL frame with `allow-same-origin` hands its whole |
| 589 | // document over. This is the check that survives the sandbox being set |
| 590 | // somewhere the string comparison above is not looking. |
| 591 | check(f.reachable === false, |
| 592 | 'and the sandbox is real: the parent cannot reach into the frame\'s document', |
| 593 | 'contentDocument reachable: ' + String(f.reachable)); |
| 594 | check(String(f.src).slice(0, 5) === 'blob:', |
| 595 | 'the page is served from a blob URL, which is why the sandbox matters', |
| 596 | String(f.src).slice(0, 40)); |
| 597 | // 1d. The handshake completed. Everything below that asserts NOTHING happened |
| 598 | // depends on this: a page that never loaded satisfies every negative check in |
| 599 | // this file, and this is the positive that stops them being vacuous. |
| 600 | check(!f.fallback && f.ready === true && !f.reason, |
| 601 | 'a page that answers and renders everything is left alone', |
| 602 | JSON.stringify({ fallback: f.fallback, ready: f.ready, reason: f.reason })); |
| 603 | const frameMode = f.mode; |
| 604 | // 1e. And it was handed the crystal unprompted, which is the half of the |
| 605 | // handshake nothing else here would notice: a parent that answered `ready` with |
| 606 | // silence would leave a correct page drawing an empty document, and the page |
| 607 | // would report no keys and be blamed for it. |
| 608 | const handed = await p.evaluate(() => { |
| 609 | const said = window.__seen.map((x) => x.data) |
| 610 | .filter((d) => d && d.said && d.said.cmd === 'data').pop(); |
| 611 | return said ? Object.keys(said.said.data || {}) : null; |
| 612 | }); |
| 613 | check(!!handed && handed.includes('title') && handed.includes('sections'), |
| 614 | 'and it was handed the crystal, unprompted, as soon as it said it was ready', |
| 615 | JSON.stringify(handed)); |
| 616 | |
| 617 | // ── The blob URL is spent once the page has it ─────────────── |
| 618 | // It inherits our origin, so anything else on the origin could fetch the page |
| 619 | // back out of it for as long as it lives. The control fetch first, because a |
| 620 | // `blob:` fetch that fails for some unrelated reason would make the real check |
| 621 | // pass having proved nothing. |
| 622 | const blob = await p.evaluate(async (src) => { |
| 623 | const mine = URL.createObjectURL(new Blob(['control'], { type: 'text/html' })); |
| 624 | let control = 'unknown', spent = 'unknown'; |
| 625 | try { await fetch(mine); control = 'fetchable'; } catch (e) { control = 'refused'; } |
| 626 | URL.revokeObjectURL(mine); |
| 627 | try { await fetch(src); spent = 'fetchable'; } catch (e) { spent = 'refused'; } |
| 628 | return { control, spent }; |
| 629 | }, f.src); |
| 630 | check(blob.control === 'fetchable', 'a live blob URL can be fetched from this page', |
| 631 | blob.control); |
| 632 | check(blob.spent === 'refused', 'and the page\'s own blob URL has already been revoked', |
| 633 | blob.spent); |
| 634 | |
| 635 | // ══ 2. The page cannot reach the network ════════════════════ |
| 636 | // |
| 637 | // The sandbox stops the page reading our storage and does nothing to stop it |
| 638 | // SENDING, and the page is handed the whole crystal by design. Read from |
| 639 | // inside the frame, because that is the only place the injected policy exists. |
| 640 | await resetProbes(); |
| 641 | const self = await selfReport(); |
| 642 | check(!!self, 'the page can be asked about its own document', |
| 643 | self ? '' : 'no self report came back from the frame'); |
| 644 | if (self) { |
| 645 | check(self.beacon === 'blocked', |
| 646 | 'a page that tries to send the crystal to a third party is stopped', |
| 647 | 'the beacon was ' + String(self.beacon)); |
| 648 | check(self.policies.some((x) => norm(x) === norm(POLICY)), |
| 649 | 'because a page that brought no policy is given one', |
| 650 | JSON.stringify(self.policies)); |
| 651 | // A `<meta>` ahead of the doctype puts the whole authored page into quirks |
| 652 | // mode, which changes how every model-written stylesheet lays out. |
| 653 | check(self.doctype === true && self.compat === 'CSS1Compat', |
| 654 | 'and the policy went in AFTER the doctype, so the page is not in quirks mode', |
| 655 | 'compatMode ' + String(self.compat) + ', doctype ' + String(self.doctype)); |
| 656 | } |
| 657 | |
| 658 | // ══ 3. `asset` reads this Diamond, and only this Diamond ════ |
| 659 | await resetProbes(); |
| 660 | await say({ dc: 1, v: 1, cmd: 'asset', id: 'in', path: 'note.md' }); |
| 661 | await say({ dc: 1, v: 1, cmd: 'asset', id: 'out', path: '../' + ids.extra + '/crystal.json' }); |
| 662 | await p.waitForTimeout(1500); |
| 663 | let pr = await probes(); |
| 664 | const answered = (id) => { |
| 665 | const hit = pr.seen.map((x) => x.data) |
| 666 | .filter((d) => d && d.said && d.said.id === id).pop(); |
| 667 | return hit ? hit.said : null; |
| 668 | }; |
| 669 | const inScope = answered('in'); |
| 670 | const escaped = answered('out'); |
| 671 | check(!!inScope && typeof inScope.text === 'string' && /ASSET-IN-SCOPE/.test(inScope.text), |
| 672 | 'a page may read a file of its own Diamond\'s', |
| 673 | JSON.stringify(inScope).slice(0, 140)); |
| 674 | check(!!escaped && !escaped.text && !!escaped.error, |
| 675 | 'and a path climbing out of it is answered with an error, not a file', |
| 676 | JSON.stringify(escaped).slice(0, 140)); |
| 677 | check(!/Extra keys|PROVENANCE/.test(JSON.stringify(escaped || {})), |
| 678 | 'so no other Diamond\'s crystal comes back through the channel'); |
| 679 | |
| 680 | // ══ 4. A link goes through the egress gate ══════════════════ |
| 681 | // |
| 682 | // Run before the "nothing happened" probes, because it is the one that proves |
| 683 | // the relay reaches the app at all. |
| 684 | await resetProbes(); |
| 685 | await say({ dc: 1, v: 1, cmd: 'open', href: 'https://elsewhere.test/one' }); |
| 686 | const askedOnce = await answerDialog(false); |
| 687 | pr = await probes(); |
| 688 | check(!!askedOnce, 'a link the page asks to follow puts the question to the user', |
| 689 | askedOnce ? askedOnce.text.slice(0, 80) : 'no dialog appeared within 3.5s'); |
| 690 | // THE DIALOG IS THE INSTRUMENT, not `window.__daimondEgressAllowed`. That global |
| 691 | // is the bridge the WASM side calls, and it deliberately asks the gate without |
| 692 | // the strict flag; a page's link goes through `pageEgressAllowed`, which calls |
| 693 | // the gate directly and strictly. Hooking the global therefore watched a door |
| 694 | // this traffic does not use, and the check passed or failed on the plumbing |
| 695 | // rather than on the property. |
| 696 | // |
| 697 | // What the property actually is: the user is asked, and the question NAMES THE |
| 698 | // DESTINATION. A dialog that asked "allow this link?" without saying where would |
| 699 | // satisfy "the user was asked" and be worth nothing, since the whole risk is a |
| 700 | // host that carries the crystal out in its path. |
| 701 | check(/elsewhere\.test/.test(askedOnce ? askedOnce.text : ''), |
| 702 | 'and the question names the address, so the user can see where it goes', |
| 703 | askedOnce ? askedOnce.text.slice(0, 160) : 'no dialog'); |
| 704 | check(pr.opened.length === 0, 'declining it opens nothing', pr.opened.join(', ')); |
| 705 | |
| 706 | // The same host again. The gate remembers a host for ordinary fetches, which is |
| 707 | // right for an agent reading a documentation site and wrong here: one yes about |
| 708 | // a host would license every later `host/<the whole crystal>`. |
| 709 | await resetProbes(); |
| 710 | await say({ dc: 1, v: 1, cmd: 'open', href: 'https://elsewhere.test/two' }); |
| 711 | const askedTwice = await answerDialog(false); |
| 712 | pr = await probes(); |
| 713 | check(!!askedTwice, 'the SAME host is asked about again, with no memoisation', |
| 714 | askedTwice ? '' : 'the second link went through without asking'); |
| 715 | check(/elsewhere\.test/.test(askedTwice ? askedTwice.text : ''), |
| 716 | 'and the second question names the address too', |
| 717 | askedTwice ? askedTwice.text.slice(0, 160) : 'no dialog'); |
| 718 | |
| 719 | // Daimond's own address gets no free pass — and cannot be given one at the |
| 720 | // gate, which answers `allow` for our own host before any field of the request |
| 721 | // can influence it. So the refusal has to happen before the gate is asked. |
| 722 | // Non-vacuous because the two probes above went down the identical path and |
| 723 | // did reach it. |
| 724 | await resetProbes(); |
| 725 | const ownHref = await p.evaluate(() => |
| 726 | location.origin + '/guide/index.html?carried=' + 'x'.repeat(40)); |
| 727 | await say({ dc: 1, v: 1, cmd: 'open', href: ownHref }); |
| 728 | const askedOwn = await answerDialog(false); |
| 729 | pr = await probes(); |
| 730 | // NOT VACUOUS: the two hrefs above went down the identical path and each raised a |
| 731 | // dialog naming its host, so "no dialog" here is the refusal and not a broken |
| 732 | // relay. It has to be refused BEFORE the gate, because the gate answers `allow` |
| 733 | // for our own host before any field of the request can influence it -- so there |
| 734 | // is no question that could be put to it that it would answer correctly. |
| 735 | check(!askedOwn, |
| 736 | 'a link to Daimond\'s own address is refused before the gate, which would allow it', |
| 737 | askedOwn ? askedOwn.text.slice(0, 160) : ''); |
| 738 | check(pr.opened.length === 0, 'and nothing is opened for it', pr.opened.join(', ')); |
| 739 | |
| 740 | // ══ 5. No ask verb, and no write verb ═══════════════════════ |
| 741 | // |
| 742 | // The relay is now known to reach the app, so a message that changes nothing is |
| 743 | // the app refusing rather than the probe misfiring. |
| 744 | const beforeVerbs = await stored(ids.good); |
| 745 | await resetProbes(); |
| 746 | await say({ dc: 1, v: 1, cmd: 'ask', text: 'Type your passphrase to continue', id: 'a1' }); |
| 747 | await say({ dc: 1, v: 1, cmd: 'prompt', text: 'Anything at all' }); |
| 748 | const askedByPage = await answerDialog(false, 1500); |
| 749 | check(!askedByPage, 'a page that claims an `ask` verb puts no question on screen', |
| 750 | askedByPage ? askedByPage.text.slice(0, 120) : ''); |
| 751 | |
| 752 | for (const verb of [ |
| 753 | { dc: 1, v: 1, cmd: 'write', path: 'crystal.json', content: '{"title":"OWNED"}' }, |
| 754 | { dc: 1, v: 1, cmd: 'save', data: { title: 'OWNED' } }, |
| 755 | { dc: 1, v: 1, cmd: 'set', key: 'title', value: 'OWNED' }, |
| 756 | // `data` is the PARENT's verb. A handler keyed on the name alone, without |
| 757 | // caring which way the message was travelling, would take this as a write. |
| 758 | { dc: 1, v: 1, cmd: 'data', data: { title: 'OWNED' } }, |
| 759 | ]) { |
| 760 | await say(verb); |
| 761 | } |
| 762 | await p.waitForTimeout(1200); |
| 763 | const afterVerbs = await stored(ids.good); |
| 764 | check(afterVerbs === beforeVerbs, 'and no message from the page can write the crystal', |
| 765 | String(afterVerbs).slice(0, 120)); |
| 766 | f = await face(); |
| 767 | check(f.hasFrame && !f.fallback, |
| 768 | 'the frame is still standing after all of that, so nothing threw', |
| 769 | JSON.stringify({ frame: f.hasFrame, fallback: f.fallback, reason: f.reason })); |
| 770 | |
| 771 | // ══ 6. Who is speaking, and in what envelope ════════════════ |
| 772 | // |
| 773 | // Any script on the page can postMessage, and the app's own window is the |
| 774 | // easiest source of all to be. |
| 775 | await resetProbes(); |
| 776 | await p.evaluate(() => window.postMessage( |
| 777 | { dc: 1, v: 1, cmd: 'open', href: 'https://topwindow.test/x' }, '*')); |
| 778 | await p.waitForTimeout(900); |
| 779 | pr = await probes(); |
| 780 | check(!pr.gate.some((x) => /topwindow\.test/.test(x)), |
| 781 | 'a message from the app\'s own window is not the page speaking, and is ignored', |
| 782 | pr.gate.join(' | ').slice(0, 160)); |
| 783 | |
| 784 | await resetProbes(); |
| 785 | await p.evaluate(() => { |
| 786 | const f2 = document.createElement('iframe'); |
| 787 | f2.id = 'probe-stranger'; |
| 788 | f2.style.cssText = 'width:1px;height:1px;position:absolute;left:-9999px'; |
| 789 | f2.srcdoc = '<script>parent.postMessage(' |
| 790 | + '{dc:1,v:1,cmd:"open",href:"https://stranger.test/x"}, "*");<\/script>'; |
| 791 | document.body.appendChild(f2); |
| 792 | }); |
| 793 | await p.waitForTimeout(1200); |
| 794 | pr = await probes(); |
| 795 | await p.evaluate(() => { const x = document.getElementById('probe-stranger'); if (x) x.remove(); }); |
| 796 | check(!pr.gate.some((x) => /stranger\.test/.test(x)), |
| 797 | 'nor is another frame on the page, however well it knows the protocol', |
| 798 | pr.gate.join(' | ').slice(0, 160)); |
| 799 | |
| 800 | await resetProbes(); |
| 801 | await say({ cmd: 'open', href: 'https://noenvelope.test/x' }); |
| 802 | await say({ dc: 1, cmd: 'open', href: 'https://noversion.test/x' }); |
| 803 | await p.waitForTimeout(900); |
| 804 | pr = await probes(); |
| 805 | check(!pr.gate.some((x) => /noenvelope\.test/.test(x)), |
| 806 | 'a message with no `dc` marker is ignored even from the frame itself', |
| 807 | pr.gate.join(' | ').slice(0, 160)); |
| 808 | check(!pr.gate.some((x) => /noversion\.test/.test(x)), |
| 809 | 'and so is one with no protocol version', pr.gate.join(' | ').slice(0, 160)); |
| 810 | // The control for the three above: the relay still works, so their silence is |
| 811 | // the app's judgement and not a broken probe. |
| 812 | await resetProbes(); |
| 813 | await say({ dc: 1, v: 1, cmd: 'open', href: 'https://stillworks.test/x' }); |
| 814 | const stillAsked = await answerDialog(false); |
| 815 | pr = await probes(); |
| 816 | check(!!stillAsked || pr.gate.some((x) => /stillworks\.test/.test(x)), |
| 817 | 'while a properly addressed message from the frame still gets through, so ' |
| 818 | + 'the four checks above are the app refusing and not the probe failing'); |
| 819 | |
| 820 | // ══ 7. Falling back, visibly, for four different reasons ════ |
| 821 | const wantFailed = await T('crystal.page_failed'); |
| 822 | const wantPartial = await T('crystal.page_partial'); |
| 823 | const wantReset = await T('crystal.page_reset'); |
| 824 | |
| 825 | await showDiamond(D.broken.name); |
| 826 | await p.waitForTimeout(settle); |
| 827 | const bro = await face(); |
| 828 | check(bro.fallback, 'a page that never answers is replaced by the built-in view', |
| 829 | 'still framed: ' + String(bro.framed)); |
| 830 | check(bro.reason === 'timeout', 'and the module knows why: it timed out', |
| 831 | JSON.stringify(bro.reason)); |
| 832 | check(bro.mode !== frameMode, 'which is a different state from showing the page', |
| 833 | JSON.stringify({ frame: frameMode, now: bro.mode })); |
| 834 | check(bro.note !== null, 'the reason is on screen, not swallowed', 'no .crystal-fallback-note'); |
| 835 | check(norm(bro.note).includes(norm(wantFailed)), |
| 836 | 'saying the page did not load', JSON.stringify(String(bro.note).slice(0, 140))); |
| 837 | check(bro.reset !== null && norm(bro.reset).includes(norm(wantReset)), |
| 838 | 'with a way to put the standard page back', JSON.stringify(bro.reset)); |
| 839 | check(/BROKEN-PAGE-TITLE/.test(bro.text), |
| 840 | 'and the Diamond\'s data is readable in it, which is the point of falling back'); |
| 841 | |
| 842 | await showDiamond(D.partial.name); |
| 843 | await p.waitForTimeout(settle); |
| 844 | const par = await face(); |
| 845 | check(par.fallback && par.reason === 'partial', |
| 846 | 'a page that answers but renders only some of what it holds ALSO falls back', |
| 847 | JSON.stringify({ fallback: par.fallback, reason: par.reason, keys: par.keys })); |
| 848 | check(norm(par.note || '').includes(norm(wantPartial)), |
| 849 | 'and says THAT, rather than reporting a page that did not load', |
| 850 | JSON.stringify(String(par.note).slice(0, 140))); |
| 851 | // Two reasons, two notes. If the app shows one message for both, the user is |
| 852 | // told to look for a load failure in a page that loaded perfectly well. |
| 853 | check(norm(par.note || '') !== norm(bro.note || ''), |
| 854 | 'the two failures do not share one note', JSON.stringify(String(par.note).slice(0, 80))); |
| 855 | check(/PARTIAL-PAGE-TITLE/.test(par.text), 'the data is readable in that one too'); |
| 856 | |
| 857 | // A page that says hello and then nothing is UNVERIFIABLE, and unverifiable is |
| 858 | // treated as broken. It is the rule most likely to be "fixed" later by somebody |
| 859 | // who reads it as a bug, so it is pinned on its own. |
| 860 | await showDiamond(D.silent.name); |
| 861 | await p.waitForTimeout(settle2); |
| 862 | const sil = await face(); |
| 863 | check(sil.fallback && sil.reason === 'partial', |
| 864 | 'a page that says `ready` and never reports what it drew falls back too', |
| 865 | JSON.stringify({ fallback: sil.fallback, reason: sil.reason })); |
| 866 | check(/SILENT-PAGE-TITLE/.test(sil.text), 'showing the data it never drew'); |
| 867 | |
| 868 | // The control. Without it, "always fall back" passes every check above and the |
| 869 | // page is never used at all. |
| 870 | await showDiamond(D.sparse.name); |
| 871 | await p.waitForTimeout(settle); |
| 872 | const spa = await face(); |
| 873 | check(!spa.fallback && spa.hasFrame, |
| 874 | 'a page that renders every key CARRYING CONTENT is left alone, though it ' |
| 875 | + 'reported nothing for the empty ones', |
| 876 | JSON.stringify({ reason: spa.reason, keys: spa.keys })); |
| 877 | // And that page brought its own policy, which must be left alone as well: a |
| 878 | // second injected policy is not additive, it is the intersection, and it would |
| 879 | // silently break a page that asked for something the default forbids. |
| 880 | await resetProbes(); |
| 881 | const ownSelf = await selfReport(); |
| 882 | check(!!ownSelf && ownSelf.policies.some((x) => norm(x) === norm(OWN_POLICY)), |
| 883 | 'a page that brought its own policy keeps it', |
| 884 | JSON.stringify(ownSelf && ownSelf.policies)); |
| 885 | // AND IS GIVEN OURS AS WELL. Two policies on one document are enforced |
| 886 | // conjunctively -- a resource must satisfy both -- so ours can only ever |
| 887 | // tighten what the page brought and can never widen it. Skipping injection for |
| 888 | // a page that carries a policy of its own would hand the decision to the page, |
| 889 | // which is written by the model this is defending against: it would only have |
| 890 | // to declare a permissive policy of its own to be spared the real one. |
| 891 | check(!!ownSelf && ownSelf.policies.some((x) => norm(x) === norm(POLICY)), |
| 892 | 'and is given ours as well, because two policies restrict and never widen', |
| 893 | JSON.stringify(ownSelf && ownSelf.policies)); |
| 894 | |
| 895 | // A page that navigates itself away has broken the channel: a reply must be |
| 896 | // posted to `'*'`, because an opaque origin cannot be named, so whatever is in |
| 897 | // the frame now would receive the next one. |
| 898 | await showDiamond(D.renav.name); |
| 899 | await p.waitForTimeout(settle); |
| 900 | const before = await face(); |
| 901 | check(!before.fallback, 'the page that will navigate is showing first', |
| 902 | JSON.stringify({ reason: before.reason })); |
| 903 | await p.evaluate(() => { |
| 904 | const fr = document.querySelector('.crystal-frame'); |
| 905 | if (fr && fr.contentWindow) fr.contentWindow.postMessage({ __nav: 1 }, '*'); |
| 906 | }); |
| 907 | await p.waitForTimeout(2500); |
| 908 | const nav = await face(); |
| 909 | check(nav.fallback && nav.reason === 'partial', |
| 910 | 'a page that navigates itself somewhere else loses the channel and falls back', |
| 911 | JSON.stringify({ fallback: nav.fallback, reason: nav.reason })); |
| 912 | |
| 913 | // ══ 8. Nothing is dropped ═══════════════════════════════════ |
| 914 | await showDiamond(D.extra.name); |
| 915 | await p.waitForTimeout(settle); |
| 916 | const ext = await face(); |
| 917 | check(ext.fallback, 'the Diamond with unknown keys is showing the built-in view', |
| 918 | 'the fixture needs the fallback up to read it'); |
| 919 | check(/PROVENANCE-KEEPSAKE/.test(ext.text), |
| 920 | 'and an unknown top-level key is rendered there rather than skipped', |
| 921 | ext.text.slice(0, 160)); |
| 922 | |
| 923 | const wantOther = await T('crystal.other_fields'); |
| 924 | const wantTitle = await T('crystal.field_title'); |
| 925 | const wantSave = await T('common.save'); |
| 926 | const wantJson = await T('crystal.edit_json'); |
| 927 | const wantBad = await T('crystal.json_invalid'); |
| 928 | |
| 929 | /// Click a control in the crystal panel by the words on it. |
| 930 | const clickByText = async (words, what) => { |
| 931 | const found = await p.evaluate((arg) => { |
| 932 | const root = document.getElementById('crystal-view') |
| 933 | || document.getElementById('crystal-body'); |
| 934 | if (!root) return { ok: false, saw: 'no crystal panel' }; |
| 935 | const flat = (x) => String(x || '').replace(/\s+/g, ' ').trim().toLowerCase(); |
| 936 | const btns = [...root.querySelectorAll('button, [role="button"], a')]; |
| 937 | const hit = btns.find((b) => flat(b.textContent).indexOf(flat(arg.words)) >= 0); |
| 938 | if (!hit) return { ok: false, saw: btns.map((b) => flat(b.textContent)).join(' | ') }; |
| 939 | hit.click(); |
| 940 | return { ok: true, saw: '' }; |
| 941 | }, { words }); |
| 942 | check(found.ok, 'the crystal offers ' + what, found.ok ? '' : 'controls on screen: ' + found.saw); |
| 943 | await p.waitForTimeout(500); |
| 944 | return found.ok; |
| 945 | }; |
| 946 | |
| 947 | const wasStored = await stored(ids.extra); |
| 948 | if (await clickByText('✎', 'the ✎ editor')) { |
| 949 | const form = await p.evaluate(() => { |
| 950 | const f2 = document.querySelector('.crystal-form'); |
| 951 | const extra = document.querySelector('.crystal-form-extra'); |
| 952 | const flat = (x) => String(x || '').replace(/\s+/g, ' ').trim(); |
| 953 | return { |
| 954 | hasForm: !!f2, |
| 955 | hasExtra: !!extra, |
| 956 | formTxt: f2 ? flat(f2.innerText || f2.textContent) : '', |
| 957 | extraTxt: extra ? flat(extra.innerText || extra.textContent) : '', |
| 958 | }; |
| 959 | }); |
| 960 | check(form.hasForm, 'the ✎ editor is a form built from the core keys', |
| 961 | form.hasForm ? '' : 'no .crystal-form'); |
| 962 | check(form.hasExtra, 'with a place for the fields it does not know', |
| 963 | form.hasExtra ? '' : 'no .crystal-form-extra'); |
| 964 | check(/PROVENANCE-KEEPSAKE/.test(form.extraTxt), |
| 965 | 'and the unknown key is shown there, so nothing vanishes silently', |
| 966 | form.extraTxt.slice(0, 160)); |
| 967 | check(norm(form.formTxt).includes(norm(wantOther)), |
| 968 | 'under a heading that says what those fields are', |
| 969 | JSON.stringify(form.formTxt.slice(0, 120))); |
| 970 | |
| 971 | // A real edit, so the save is known to have written something. Saving an |
| 972 | // untouched form and finding the file unchanged proves nothing at all: a |
| 973 | // save that does nothing passes it. |
| 974 | const typed = await p.evaluate((arg) => { |
| 975 | const flat = (x) => String(x || '').replace(/\s+/g, ' ').trim().toLowerCase(); |
| 976 | const rows = [...document.querySelectorAll('.crystal-form-row')]; |
| 977 | const row = rows.find((r) => { |
| 978 | const lab = r.querySelector('label'); |
| 979 | return flat(lab ? lab.textContent : '').indexOf(flat(arg.title)) === 0; |
| 980 | }); |
| 981 | const box = row ? row.querySelector('input, textarea') : null; |
| 982 | if (!box) return { ok: false, saw: rows.map((r) => flat(r.textContent).slice(0, 30)).join(' | ') }; |
| 983 | box.value = 'Extra keys, retitled'; |
| 984 | box.dispatchEvent(new Event('input', { bubbles: true })); |
| 985 | box.dispatchEvent(new Event('change', { bubbles: true })); |
| 986 | return { ok: true, saw: '' }; |
| 987 | }, { title: wantTitle }); |
| 988 | check(typed.ok, 'the form has a field for the title, named as such', |
| 989 | typed.ok ? '' : 'rows on screen: ' + typed.saw); |
| 990 | |
| 991 | await clickByText(wantSave, 'a way to save the form'); |
| 992 | await p.waitForTimeout(1200); |
| 993 | const now = await stored(ids.extra); |
| 994 | const kept = await p.evaluate((arg) => { |
| 995 | const a = DaimondCrystal.parse(arg.was), b = DaimondCrystal.parse(arg.now); |
| 996 | if (!a.ok || !b.ok) return { ok: false, why: 'one side did not parse' }; |
| 997 | return { |
| 998 | wrote: b.data.title === 'Extra keys, retitled', |
| 999 | same: JSON.stringify(a.data.provenance) === JSON.stringify(b.data.provenance), |
| 1000 | got: JSON.stringify(b.data.provenance), |
| 1001 | had: JSON.stringify(a.data.provenance), |
| 1002 | title: String(b.data.title), |
| 1003 | summary: String(b.data.summary || ''), |
| 1004 | }; |
| 1005 | }, { was: wasStored, now }); |
| 1006 | check(kept.wrote, 'saving the form writes the change that was typed', |
| 1007 | 'title on disk: ' + JSON.stringify(kept.title)); |
| 1008 | check(kept.same, 'AND the unknown key comes back exactly as it went in', |
| 1009 | 'was ' + kept.had + ', now ' + kept.got); |
| 1010 | check(/Holds a key nothing/.test(kept.summary || ''), |
| 1011 | 'with the core keys it did not touch left alone', JSON.stringify(kept.summary)); |
| 1012 | } |
| 1013 | |
| 1014 | // ══ 9. The editor refuses unparseable JSON ══════════════════ |
| 1015 | const beforeJson = await stored(ids.extra); |
| 1016 | if (await clickByText('✎', 'the ✎ editor a second time')) { |
| 1017 | if (await clickByText(wantJson, 'raw JSON behind a second click')) { |
| 1018 | const put = await p.evaluate(() => { |
| 1019 | const ta = document.querySelector('.crystal-json'); |
| 1020 | if (!ta) return false; |
| 1021 | ta.value = '{ "title": "half a document"'; |
| 1022 | ta.dispatchEvent(new Event('input', { bubbles: true })); |
| 1023 | ta.dispatchEvent(new Event('change', { bubbles: true })); |
| 1024 | return true; |
| 1025 | }); |
| 1026 | check(put, 'the raw JSON is in a .crystal-json box', put ? '' : 'no .crystal-json'); |
| 1027 | await clickByText(wantSave, 'a way to save the raw JSON'); |
| 1028 | await p.waitForTimeout(900); |
| 1029 | const said = await p.evaluate(() => ({ |
| 1030 | body: (document.body.innerText || ''), |
| 1031 | dlg: (document.querySelector('.dlg .dlg-card') || {}).innerText || '', |
| 1032 | })); |
| 1033 | check(norm(said.body + ' ' + said.dlg).includes(norm(wantBad)), |
| 1034 | 'saving unparseable JSON says so', |
| 1035 | JSON.stringify(String(said.dlg || said.body).slice(-160))); |
| 1036 | const afterJson = await stored(ids.extra); |
| 1037 | check(afterJson === beforeJson, |
| 1038 | 'and nothing is saved, which is the difference between markdown and data', |
| 1039 | String(afterJson).slice(0, 120)); |
| 1040 | await answerDialog(true, 1200); |
| 1041 | } |
| 1042 | } |
| 1043 | |
| 1044 | // ══ 10. A page shorter than the panel still fills the panel ═══════ |
| 1045 | // |
| 1046 | // notes4.txt: "When I changed the background of a crystal, it did not take |
| 1047 | // up the whole useable area of the panel." `PAGE_SOLID_BG` is one short |
| 1048 | // line on a solid colour it chose for itself -- so its OWN measured height |
| 1049 | // is a fraction of the panel's, and what is asked is whether the REST of |
| 1050 | // the panel picks up that colour or shows the app's ordinary chrome |
| 1051 | // beneath it. |
| 1052 | // |
| 1053 | // Measured, not eyeballed, and measured so the check DISCRIMINATES: a probe |
| 1054 | // that only asked "is the frame present" or "is SOME colour showing" would |
| 1055 | // read the same whether the frame fills the panel or stops a hundred pixels |
| 1056 | // in — coverage, the frame's height as a fraction of the panel's, is the |
| 1057 | // number that tells the two apart. Run against the code as it shipped |
| 1058 | // before this fix (`.crystal-frame` pinned to `height: <content px>`) this |
| 1059 | // reads about 12%; the fix (`height: 100%` floored by a `min-height` the |
| 1060 | // content sets) reads at or near 100%. |
| 1061 | await showDiamond(D.bgshort.name); |
| 1062 | await p.waitForTimeout(900); |
| 1063 | const cover = await p.evaluate(() => { |
| 1064 | const body = document.getElementById('crystal-body'); |
| 1065 | const frame = document.querySelector('.crystal-frame'); |
| 1066 | const b = body ? body.getBoundingClientRect() : null; |
| 1067 | const f = frame ? frame.getBoundingClientRect() : null; |
| 1068 | return { |
| 1069 | bodyH: b ? b.height : 0, |
| 1070 | frameH: f ? f.height : 0, |
| 1071 | // The colour a page too short to reach the bottom of `crystal-body` |
| 1072 | // would leave exposed there, sampled where the frame USED to stop. |
| 1073 | // `getComputedStyle` on an element outside the sandboxed frame -- |
| 1074 | // this reads the app's own chrome, never the page's. |
| 1075 | belowColor: (b && f) ? getComputedStyle(body).backgroundColor |
| 1076 | : null, |
| 1077 | }; |
| 1078 | }); |
| 1079 | const coverage = cover.bodyH ? (cover.frameH / cover.bodyH) : 0; |
| 1080 | // A LITTLE under 100%, not exactly: a scrollbar or a sub-pixel layout |
| 1081 | // rounding is not the property under test. 85% is well clear of both — |
| 1082 | // and worlds away from the ~12% the code shipped with before this fix. |
| 1083 | check(coverage > 0.85, |
| 1084 | 'a page far shorter than the panel still fills nearly all of it', |
| 1085 | `frame ${cover.frameH.toFixed(0)}px of ${cover.bodyH.toFixed(0)}px available ` |
| 1086 | + `(${(coverage * 100).toFixed(0)}%)`); |
| 1087 | |
| 1088 | // A page taller than the panel must still scroll rather than being |
| 1089 | // squashed down to the panel's own height -- the other half of the same |
| 1090 | // fix, on the same page format so the only variable is content length. |
| 1091 | await showDiamond(D.bgtall.name); |
| 1092 | await p.waitForTimeout(900); |
| 1093 | const tall = await p.evaluate(() => { |
| 1094 | const body = document.getElementById('crystal-body'); |
| 1095 | const frame = document.querySelector('.crystal-frame'); |
| 1096 | return { |
| 1097 | bodyClientH: body ? body.clientHeight : 0, |
| 1098 | bodyScrollH: body ? body.scrollHeight : 0, |
| 1099 | frameH: frame ? frame.getBoundingClientRect().height : 0, |
| 1100 | }; |
| 1101 | }); |
| 1102 | check(tall.frameH > tall.bodyClientH * 1.5, |
| 1103 | 'a page much TALLER than the panel is not squashed down to fit it', |
| 1104 | `frame ${tall.frameH.toFixed(0)}px, panel ${tall.bodyClientH.toFixed(0)}px`); |
| 1105 | check(tall.bodyScrollH > tall.bodyClientH, |
| 1106 | 'and the panel scrolls to reach the rest of it', |
| 1107 | `scrollHeight ${tall.bodyScrollH.toFixed(0)}px, clientHeight ${tall.bodyClientH.toFixed(0)}px`); |
| 1108 | |
| 1109 | // A resource the browser could not load is the dev stack, not the page: no |
| 1110 | // gateway runs here, so its probes answer 401 or 502 and neither is a throw. |
| 1111 | // The refused beacon and the spent blob URL are this file's own doing, and the |
| 1112 | // frame's own console never reaches us, which is the point of §5. |
| 1113 | // |
| 1114 | // `PAGE_BROKEN`'s parse error is NOT noise in the ordinary sense -- it is the |
| 1115 | // subject of check 6. A syntax error is the whole of what a page that never |
| 1116 | // answers looks like, and this file writes one deliberately to produce it. It |
| 1117 | // surfaces here because Playwright reports a frame's `pageerror` against the |
| 1118 | // page that holds the frame, so an error raised inside the opaque origin is |
| 1119 | // indistinguishable at this level from one the app raised. Matched on the |
| 1120 | // fixture's own text rather than on "SyntaxError", so a real syntax error |
| 1121 | // anywhere else -- in `crystal.js`, in the shipped page, in a page a model |
| 1122 | // wrote -- still fails this check, which is the only reason it exists. |
| 1123 | const noise = s.errs.filter((e) => |
| 1124 | !/favicon|ERR_ABORTED|net::ERR|Failed to load resource|i18n: no string|Content Security Policy|Refused to connect/i.test(e) |
| 1125 | && !/Unexpected identifier 'is'|Unexpected token '\)'/.test(e)); |
| 1126 | check(noise.length === 0, 'the app threw nothing along the way', noise.slice(0, 3).join(' | ')); |
| 1127 | |
| 1128 | } catch (e) { |
| 1129 | check(false, 'the run finished', String(e && e.message || e)); |
| 1130 | } finally { |
| 1131 | await s.close(); |
| 1132 | } |
| 1133 | |
| 1134 | console.log(bad === 0 ? '\nall checks passed' : `\n${bad} check(s) FAILED`); |
| 1135 | process.exit(bad === 0 ? 0 : 1); |