Oregami
Repositories/oxedyne/daimond

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.
56import fs from 'node:fs';
57import path from 'node:path';
58import { fileURLToPath } from 'node:url';
59import { open, signInAs, connectMock } from './harness.mjs';
60
61const HERE = path.dirname(fileURLToPath(import.meta.url));
62const WWW = path.join(HERE, '..', 'www');
63
64const BREAK = (() => {
65 const i = process.argv.indexOf('--break');
66 return i > 0 ? String(process.argv[i + 1] || '') : '';
67})();
68
69const 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
124const ok = [], bad = [];
125const check = (name, pass, detail) => {
126 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
127 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
128};
129
130const s = await open({ name: 'capp', signIn: false, connect: false });
131const { page } = s;
132
133if (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
159await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' });
160await signInAs(s, 'capp');
161await connectMock(s);
162await page.waitForTimeout(1500);
163
164try {
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
346console.log(`\n${ok.length} passed, ${bad.length} failed`);
347if (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}
352process.exit(bad.length ? 1 : 0);