oxedyne/daimond/dev/verify_capp.mjs
17.5 KiB, 1 run
created by r2519314175:259, 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_capp.mjs — a crystal page can keep what you tell it, and nothing else. |
| 2 | // |
| 3 | // WHAT THIS IS FOR. A crystal page is HTML, CSS and JavaScript in a frame that is |
| 4 | // `sandbox="allow-scripts"` and nothing else, under `default-src 'none'`. So it has no network, |
| 5 | // and its origin is opaque, which means `localStorage` throws. It could already READ files from |
| 6 | // its own Diamond (the `asset` verb) — so it could draw a chart of data somebody else had put |
| 7 | // there, and it could not record a single thing the person using it did. Every interactive |
| 8 | // crystal was a toy that forgot on reload. |
| 9 | // |
| 10 | // The `save` verb is the other half, and it is deliberately the SAME shape as `asset`: the page |
| 11 | // asks, the app writes. `postMessage` crosses an opaque origin perfectly well, so this needs no |
| 12 | // relaxation of the sandbox at all — which is better than granting the frame storage, because |
| 13 | // the app stays the thing that decides what may be touched. |
| 14 | // |
| 15 | // The properties, and the last three matter more than the first two: |
| 16 | // |
| 17 | // 1. A page can SAVE a file into its own Diamond, and read back what it saved. |
| 18 | // 2. APPEND adds to what is there; two appends in the same tick both survive. A logger that |
| 19 | // loses a line under a double tap is not a logger. NOTE what this does NOT claim: appends |
| 20 | // made on two DEVICES do not merge. Sync replaces a Diamond wholesale from whichever copy |
| 21 | // is fresher, so append buys a whole log on the winning side rather than a union of both. |
| 22 | // An earlier version of this comment said otherwise and was wrong. |
| 23 | // 3. A page CANNOT WRITE ITSELF. `crystal.html` is the code and `crystal.json` is the memory; |
| 24 | // a page that could write either could change what it does between one render and the |
| 25 | // next, and nothing anybody reviewed would stay reviewed. |
| 26 | // 4. A page CANNOT LEAVE ITS DIAMOND. Not by `..`, not by an absolute path, not by a scheme. |
| 27 | // 5. A page CANNOT TOUCH `.daimond/` or `versions/` — the rules about what agents may do, and |
| 28 | // the crystal's own history. |
| 29 | // 6. A WRITE STAMPS THE DIAMOND, so what a capp logged travels to the other devices. Without |
| 30 | // this a phone where the user only ever logged into a capp stays the STALE side and its |
| 31 | // log is replaced wholesale by the other device's copy — the tag-loss shape of |
| 32 | // 2026-08-11, through a new door. |
| 33 | // 7. A RUNAWAY PAGE IS BOUNDED. A click and a timer are indistinguishable from outside the |
| 34 | // frame, so the loop is capped rather than trusted. |
| 35 | // |
| 36 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. |
| 37 | // |
| 38 | // node dev/verify_capp.mjs --break unfenced # 3 and 5 fail: the protected-name guard goes |
| 39 | // node dev/verify_capp.mjs --break escapable # 4 fails: the path fence goes, in BOTH files |
| 40 | // node dev/verify_capp.mjs --break unstamped # 6 fails: the write no longer stamps the Diamond |
| 41 | // node dev/verify_capp.mjs --break boundless # 7 fails: the runaway bound comes off |
| 42 | // node dev/verify_capp.mjs --break clobber # 2 fails: append becomes replace |
| 43 | // node dev/verify_capp.mjs --break racy # 2's second half fails: appends stop serialising |
| 44 | // node dev/verify_capp.mjs # and then, clean |
| 45 | // |
| 46 | // `unfenced` and `escapable` are two breaks and not one because properties 3 and 4 are held by |
| 47 | // DIFFERENT lines: the protected names by `PAGE_NEVER_WRITES`, the escapes by `safePath`. The |
| 48 | // first version of this file claimed one break covered both, and running it showed all four |
| 49 | // escape checks still green -- they were guarded by a line the break never touched. A red run is |
| 50 | // not evidence unless the break reaches every site that guards the property, and property 4 is |
| 51 | // guarded twice over, in `crystal.js` and again in `writeCrystalAsset`. |
| 52 | // |
| 53 | // The breaks go on the app's own guard rather than on the page, because the page is untrusted by |
| 54 | // construction: what is under test is what the APP refuses, and a break in the page would only |
| 55 | // prove that a page which does not ask does not receive. |
| 56 | import fs from 'node:fs'; |
| 57 | import path from 'node:path'; |
| 58 | import { fileURLToPath } from 'node:url'; |
| 59 | import { open, signInAs, connectMock } from './harness.mjs'; |
| 60 | |
| 61 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 62 | const WWW = path.join(HERE, '..', 'www'); |
| 63 | |
| 64 | const BREAK = (() => { |
| 65 | const i = process.argv.indexOf('--break'); |
| 66 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 67 | })(); |
| 68 | |
| 69 | const BREAKS = { |
| 70 | // The guard that stops a page rewriting itself and leaving its Diamond. |
| 71 | unfenced: { |
| 72 | file: 'js/crystal.js', |
| 73 | find: " if (PAGE_NEVER_WRITES.test(rel)) { toFrame({ id: id, error: 'protected' }); return; }", |
| 74 | with: " if (false) { toFrame({ id: id, error: 'protected' }); return; }", |
| 75 | }, |
| 76 | // Append quietly becoming replace: the shape a logger loses a day's meals to. |
| 77 | clobber: { |
| 78 | file: 'js/daimond.js', |
| 79 | find: " if (mode === 'append') {", |
| 80 | with: " if (false) {", |
| 81 | }, |
| 82 | // The path fence, in BOTH places that hold it. `unfenced` above does NOT turn the escape |
| 83 | // checks red -- it removes the protected-name guard, and a `../` is stopped by `safePath`, |
| 84 | // a different line entirely. That was found by running the break and reading which checks |
| 85 | // actually moved, which is the only way this kind of gap is ever found. |
| 86 | escapable: [ |
| 87 | { |
| 88 | // Anchored on the PAGE_NEVER_WRITES line, which only `onSave` has: the two lines above |
| 89 | // it are byte-identical in `onAsset`, and the anchor guard caught that rather than |
| 90 | // letting a break land in the reader and be reported as a fence that did not hold. |
| 91 | file: 'js/crystal.js', |
| 92 | find: "\t\tvar rel = safePath(m.path);\n\t\tif (!rel) { toFrame({ id: id, error: 'path' }); return; }\n\t\tif (PAGE_NEVER_WRITES.test(rel))", |
| 93 | with: "\t\tvar rel = str(m.path);\n\t\tif (PAGE_NEVER_WRITES.test(rel))", |
| 94 | }, |
| 95 | { |
| 96 | file: 'js/daimond.js', |
| 97 | find: "\t\tif (path.indexOf(home) !== 0 || path.indexOf('..') >= 0) {\n\t\t\tthrow new Error('Not a path in this Diamond: ' + String(rel == null ? path : rel));\n\t\t}\n\t\t// One write at a time, per page.", |
| 98 | with: "\t\t// One write at a time, per page.", |
| 99 | }, |
| 100 | ], |
| 101 | // The stamp, so what a capp logged never travels. Seen red by accident first -- the check |
| 102 | // failed while the wasm was a build behind and `Wasm.touch_diamond` was undefined, which the |
| 103 | // caller's own catch swallowed. That was a true red for the right reason, and it is a break |
| 104 | // here as well so it stays one. |
| 105 | unstamped: { |
| 106 | file: 'js/daimond.js', |
| 107 | find: " try { await Wasm.touch_diamond(id); } catch (e) { /* the bytes are down; say nothing */ }", |
| 108 | with: " // stamp removed", |
| 109 | }, |
| 110 | // The bound comes off, so a page with a loop in it writes for ever. |
| 111 | boundless: { |
| 112 | file: 'js/crystal.js', |
| 113 | find: " if (live.saves > SAVE_BUDGET) { toFrame({ id: id, error: 'too many' }); return; }", |
| 114 | with: " if (false) { toFrame({ id: id, error: 'too many' }); return; }", |
| 115 | }, |
| 116 | // The appends stop queueing, so two in one tick each read the file before either wrote. |
| 117 | racy: { |
| 118 | file: 'js/daimond.js', |
| 119 | find: " _cappWrite = _cappWrite.then(async function () {", |
| 120 | with: " _cappWrite = Promise.resolve().then(async function () {", |
| 121 | }, |
| 122 | }; |
| 123 | |
| 124 | const ok = [], bad = []; |
| 125 | const check = (name, pass, detail) => { |
| 126 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 127 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 128 | }; |
| 129 | |
| 130 | const s = await open({ name: 'capp', signIn: false, connect: false }); |
| 131 | const { page } = s; |
| 132 | |
| 133 | if (BREAK) { |
| 134 | const spec = BREAKS[BREAK]; |
| 135 | if (!spec) { console.error(`no such break: ${BREAK}`); process.exit(2); } |
| 136 | // A break may name SEVERAL sites, and one of them has to. `escapable` below neuters the |
| 137 | // path fence in two files, because the property is guarded in two files -- and a break that |
| 138 | // reached only one would leave the other holding, go green, and be reported as a check that |
| 139 | // cannot fail when in truth it was never tested. |
| 140 | const sites = Array.isArray(spec) ? spec : [spec]; |
| 141 | const edited = new Map(); |
| 142 | for (const site of sites) { |
| 143 | const src = edited.get(site.file) || fs.readFileSync(path.join(WWW, site.file), 'utf8'); |
| 144 | const n = src.split(site.find).length - 1; |
| 145 | if (n !== 1) { |
| 146 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${site.file}, ` |
| 147 | + 'so nothing was broken and the run below would prove nothing.'); |
| 148 | process.exit(2); |
| 149 | } |
| 150 | edited.set(site.file, src.replace(site.find, site.with)); |
| 151 | } |
| 152 | for (const [file, body] of edited) { |
| 153 | await page.route('**/' + file, r => r.fulfill({ |
| 154 | status: 200, contentType: 'application/javascript', body, |
| 155 | })); |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' }); |
| 160 | await signInAs(s, 'capp'); |
| 161 | await connectMock(s); |
| 162 | await page.waitForTimeout(1500); |
| 163 | |
| 164 | try { |
| 165 | await page.click('#new-diamond-btn', { force: true }); |
| 166 | await page.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 167 | await page.fill('.dlg-input', 'Logger'); |
| 168 | await page.click('.dlg-ok', { force: true }); |
| 169 | await page.waitForTimeout(1800); |
| 170 | await page.$$eval('.diamond-box', els => els[0] && els[0].click()); |
| 171 | await page.waitForTimeout(1200); |
| 172 | |
| 173 | const id = await page.evaluate(async () => { |
| 174 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 175 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 176 | window.__free = app; |
| 177 | const d = JSON.parse(await app.list_diamonds()).find(x => x.name === 'Logger'); |
| 178 | return d ? d.id : ''; |
| 179 | }); |
| 180 | check('a Diamond for the capp to live in', !!id, id); |
| 181 | |
| 182 | // A page that logs, written the way a daimon would write one: it asks for what it needs |
| 183 | // through the protocol and holds nothing of its own. |
| 184 | const PAGE = `<!doctype html><meta charset="utf-8"><body><div id="out">idle</div><script> |
| 185 | var P = 1, seq = 0, waiting = {}; |
| 186 | function send(cmd, extra) { |
| 187 | var id = 'r' + (++seq); |
| 188 | var msg = Object.assign({ dc: 1, v: P, cmd: cmd, id: id }, extra || {}); |
| 189 | return new Promise(function (res) { waiting[id] = res; parent.postMessage(msg, '*'); }); |
| 190 | } |
| 191 | window.addEventListener('message', function (e) { |
| 192 | var m = e.data; |
| 193 | if (!m || m.dc !== 1) return; |
| 194 | if (m.id && waiting[m.id]) { waiting[m.id](m); delete waiting[m.id]; return; } |
| 195 | if (m.cmd === 'data') { |
| 196 | // Every top-level key of the data that has content must be named, or the app judges |
| 197 | // the page to have drawn only part of the crystal and falls back to its own |
| 198 | // rendering -- which tears the frame down and is exactly what a capp must not do. |
| 199 | var d = (m.data && m.data.data) ? m.data.data : (m.data || {}); |
| 200 | var ks = []; |
| 201 | for (var k in d) if (Object.prototype.hasOwnProperty.call(d, k)) ks.push(k); |
| 202 | document.getElementById('out').textContent = ks.join(','); |
| 203 | parent.postMessage({ dc:1, v:P, cmd:'rendered', keys: ks }, '*'); |
| 204 | } |
| 205 | }); |
| 206 | window.__capp = { |
| 207 | save: function (p, t, mode) { return send('save', { path: p, text: t, mode: mode }); }, |
| 208 | read: function (p) { return send('asset', { path: p }); }, |
| 209 | }; |
| 210 | parent.postMessage({ dc: 1, v: P, cmd: 'ready' }, '*'); |
| 211 | <\/script></body>`; |
| 212 | |
| 213 | await page.evaluate(async (a) => { |
| 214 | await window.__free.run_tool('file_write', JSON.stringify({ |
| 215 | path: 'diamonds/' + a.id + '/crystal.json', |
| 216 | content: JSON.stringify({ title: 'Logger', summary: 'a capp' }), |
| 217 | })); |
| 218 | await window.__free.run_tool('file_write', JSON.stringify({ |
| 219 | path: 'diamonds/' + a.id + '/crystal.html', content: a.page, |
| 220 | })); |
| 221 | }, { id, page: PAGE }); |
| 222 | |
| 223 | // Re-select the Diamond so the crystal renders AFTER the page was written; selecting it |
| 224 | // before would have mounted the stock page and this file would be measuring that. |
| 225 | await page.evaluate(() => DaimondPanels.show('ai')); |
| 226 | await page.waitForTimeout(400); |
| 227 | await page.$$eval('.diamond-box', els => els[0] && els[0].click()); |
| 228 | await page.waitForTimeout(3000); |
| 229 | |
| 230 | |
| 231 | /// Call into the page, through the frame, exactly as a tap would. |
| 232 | const inFrame = async (fn, arg) => { |
| 233 | // The crystal page is served from a blob: URL, which is what distinguishes it from the |
| 234 | // guide frame -- and the guide is a child of the main frame too, so a parent test finds |
| 235 | // that one first and every check below then measures the guide. |
| 236 | const f = page.frames().find(fr => fr.url().indexOf('blob:') === 0); |
| 237 | if (!f) throw new Error('the crystal frame is not mounted'); |
| 238 | return await f.evaluate(fn, arg); |
| 239 | }; |
| 240 | |
| 241 | // The frame really mounted and the page really answered `ready` — asserted before anything |
| 242 | // is measured through it, because every check below reads a reply from inside it and a |
| 243 | // frame that never loaded would make all of them fail for one reason that is not theirs. |
| 244 | const alive = await inFrame(() => !!(window.__capp && window.__capp.save)); |
| 245 | check('the capp page is mounted and talking to the app', alive === true, String(alive)); |
| 246 | |
| 247 | // ── 1. It can save, and what it saved is really on disk. |
| 248 | const r1 = await inFrame(() => window.__capp.save('log/diet.jsonl', |
| 249 | '{"t":1,"food":"oats","g":80}', 'append')); |
| 250 | check('A PAGE CAN SAVE INTO ITS OWN DIAMOND', !!(r1 && r1.ok), |
| 251 | JSON.stringify(r1)); |
| 252 | const onDisk = await page.evaluate((did) => window.__free |
| 253 | .run_tool('file_read', JSON.stringify({ path: 'diamonds/' + did + '/log/diet.jsonl' })) |
| 254 | .then(String).catch(e => 'ERR ' + e), id); |
| 255 | check('and the file is really there, read back outside the page', |
| 256 | /oats/.test(onDisk), onDisk.slice(0, 60).replace(/\n/g, ' ')); |
| 257 | |
| 258 | // ── 2. Append adds. Twice in one tick, both survive. |
| 259 | await inFrame(async () => { |
| 260 | await Promise.all([ |
| 261 | window.__capp.save('log/diet.jsonl', '{"t":2,"food":"eggs","g":100}', 'append'), |
| 262 | window.__capp.save('log/diet.jsonl', '{"t":3,"food":"rice","g":150}', 'append'), |
| 263 | ]); |
| 264 | }); |
| 265 | const after = await page.evaluate((did) => window.__free |
| 266 | .run_tool('file_read', JSON.stringify({ path: 'diamonds/' + did + '/log/diet.jsonl' })) |
| 267 | .then(String).catch(e => 'ERR ' + e), id); |
| 268 | check('APPEND KEEPS WHAT WAS THERE', /oats/.test(after), after.slice(0, 40).replace(/\n/g, ' ')); |
| 269 | check('AND TWO APPENDS IN ONE TICK BOTH SURVIVE', |
| 270 | /eggs/.test(after) && /rice/.test(after), |
| 271 | 'eggs:' + /eggs/.test(after) + ' rice:' + /rice/.test(after)); |
| 272 | |
| 273 | // ── 3. It cannot write itself. The control beside it is check 1: saving works, so a |
| 274 | // refusal here is a refusal of THAT rather than of everything. |
| 275 | for (const [what, p] of [['crystal.html', 'crystal.html'], ['crystal.json', 'crystal.json']]) { |
| 276 | const r = await inFrame((pp) => window.__capp.save(pp, 'pwned', 'replace'), p); |
| 277 | check('A PAGE CANNOT REWRITE ITS OWN ' + what.toUpperCase(), |
| 278 | !!(r && r.error) && !r.ok, JSON.stringify(r)); |
| 279 | } |
| 280 | const stillPage = await page.evaluate((did) => window.__free |
| 281 | .run_tool('file_read', JSON.stringify({ path: 'diamonds/' + did + '/crystal.html' })) |
| 282 | .then(String).catch(e => 'ERR ' + e), id); |
| 283 | check('and the page on disk is untouched, not merely the reply refused', |
| 284 | !/pwned/.test(stillPage) && /__capp/.test(stillPage), |
| 285 | stillPage.slice(0, 40).replace(/\n/g, ' ')); |
| 286 | |
| 287 | // ── 4. It cannot leave the Diamond. |
| 288 | for (const p of ['../evil.txt', '/etc/passwd', 'https://example.com/x', '..\\evil.txt']) { |
| 289 | const r = await inFrame((pp) => window.__capp.save(pp, 'out', 'replace'), p); |
| 290 | check('A PAGE CANNOT WRITE OUTSIDE ITS DIAMOND: ' + p, |
| 291 | !!(r && r.error) && !r.ok, JSON.stringify(r)); |
| 292 | } |
| 293 | |
| 294 | // ── 5. Nor the rules, nor the history. |
| 295 | for (const p of ['.daimond/config.json', 'versions/0000.md']) { |
| 296 | const r = await inFrame((pp) => window.__capp.save(pp, 'no', 'replace'), p); |
| 297 | check('A PAGE CANNOT WRITE ' + p, !!(r && r.error) && !r.ok, JSON.stringify(r)); |
| 298 | } |
| 299 | |
| 300 | // ── 6. The write stamped the Diamond, so the log will travel. |
| 301 | const stamped = await page.evaluate(async (did) => { |
| 302 | const raw = await window.__free.run_tool('file_read', JSON.stringify({ |
| 303 | path: 'diamonds/' + did + '/.daimond/meta.json' })).then(String).catch(() => ''); |
| 304 | const m = /"touched"\s*:\s*(\d+)/.exec(raw); |
| 305 | return m ? Number(m[1]) : 0; |
| 306 | }, id); |
| 307 | const before = stamped; |
| 308 | await inFrame(() => window.__capp.save('log/diet.jsonl', '{"t":9,"food":"tea"}', 'append')); |
| 309 | await page.waitForTimeout(600); |
| 310 | const after2 = await page.evaluate(async (did) => { |
| 311 | const raw = await window.__free.run_tool('file_read', JSON.stringify({ |
| 312 | path: 'diamonds/' + did + '/.daimond/meta.json' })).then(String).catch(() => ''); |
| 313 | const m = /"touched"\s*:\s*(\d+)/.exec(raw); |
| 314 | return m ? Number(m[1]) : 0; |
| 315 | }, id); |
| 316 | check('A WRITE STAMPS THE DIAMOND, so the log travels to the other devices', |
| 317 | after2 > 0 && after2 > before, before + ' -> ' + after2); |
| 318 | |
| 319 | // ── 7. A page that writes in a loop is stopped, with the frame still up. |
| 320 | const runaway = await inFrame(async () => { |
| 321 | var last = null; |
| 322 | for (var i = 0; i < 420; i++) { |
| 323 | last = await window.__capp.save('log/spam.jsonl', 'x', 'append'); |
| 324 | if (last && last.error) return { at: i, error: last.error }; |
| 325 | } |
| 326 | return { at: -1, error: '' }; |
| 327 | }); |
| 328 | check('A RUNAWAY PAGE IS BOUNDED rather than trusted', |
| 329 | !!(runaway && runaway.error === 'too many'), JSON.stringify(runaway)); |
| 330 | const stillThere = await inFrame(() => !!(window.__capp && window.__capp.save)); |
| 331 | check('and the frame is still up after the refusal, so the app did not fall over', |
| 332 | stillThere === true, String(stillThere)); |
| 333 | |
| 334 | // And the read half still works, which is what makes a capp worth having: data in, log out. |
| 335 | const back = await inFrame(() => window.__capp.read('log/diet.jsonl')); |
| 336 | check('a page reads back its own log, which is how a chart gets drawn', |
| 337 | !!(back && /oats/.test(String(back.text || ''))), |
| 338 | String((back && back.text) || back && back.error || '').slice(0, 50)); |
| 339 | |
| 340 | } catch (e) { |
| 341 | check('the run completed', false, String(e && e.message || e)); |
| 342 | } finally { |
| 343 | await s.close?.().catch(() => {}); |
| 344 | } |
| 345 | |
| 346 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 347 | if (BREAK) { |
| 348 | console.log(bad.length |
| 349 | ? `\nbreak '${BREAK}' produced failures, as it must.` |
| 350 | : `\nBREAK '${BREAK}' CHANGED NOTHING — the check it targets is not proving anything.`); |
| 351 | } |
| 352 | process.exit(bad.length ? 1 : 0); |