oxedyne/daimond/dev/verify_writeguard.mjs
4.6 KiB, 1 run
created by r2519314175:831, 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 | // The stale-write guard: an agent that read a file, then finds it changed on |
| 2 | // disk (another agent), must have its whole-file write REFUSED, not clobber. |
| 3 | // |
| 4 | // THE FIXTURE LIVES IN THE CHAT'S OWN SCRATCH, and moving it back to the workspace |
| 5 | // root would make this file vacuous again. Until 2026-08-12 it seeded and wrote |
| 6 | // `g.txt` at the root. Since the chat fence landed, a chat is confined to |
| 7 | // `chats/<id>/work` (`scopeChatTo`, www/js/daimond.js), and `Tool::guard` |
| 8 | // (src/tools.rs:5490) refuses a root path BEFORE the `Tool::FileWrite` arm at :5894, |
| 9 | // which is where the `read_seen` hash comparison and the stale-write refusal live. |
| 10 | // The fence preempted the guard, so nothing was written and `AGENT B WORK PRESERVED` |
| 11 | // was true for the wrong reason: it passed with the entire stale-write guard deleted, |
| 12 | // because the write never reached it. Seeded and written inside the scratch, the guard |
| 13 | // is reachable again and the check has a subject. |
| 14 | import { open, chat, newChat, errors } from './harness.mjs'; |
| 15 | |
| 16 | const ok = [], bad = []; |
| 17 | const check = (name, pass, detail) => { |
| 18 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 19 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 20 | }; |
| 21 | |
| 22 | const s = await open({ name: 'writeguard' }); |
| 23 | await newChat(s); |
| 24 | |
| 25 | // Where this chat may write, asked of the app: the fence is BUILT from |
| 26 | // `chatScratch`, so a path composed here would be a second opinion about it. |
| 27 | const dir = await s.page.evaluate(() => { |
| 28 | const f = window.DaimondAttach.focus(); |
| 29 | return f && f.id ? window.DaimondAttach.chatScratch(f.id) : ''; |
| 30 | }); |
| 31 | check('the chat has a scratch folder, which is what the fence lets it write in', |
| 32 | !!dir, dir || '(no chat in focus)'); |
| 33 | const G = dir + '/g.txt'; |
| 34 | |
| 35 | /// Write a file straight into OPFS, creating the folders on the way — this is the |
| 36 | /// OTHER agent, so it goes nowhere near a turn. |
| 37 | const putFile = (p, body) => s.page.evaluate(async ([p, body]) => { |
| 38 | const parts = p.split('/'); |
| 39 | let d = await navigator.storage.getDirectory(); |
| 40 | for (const seg of parts.slice(0, -1)) d = await d.getDirectoryHandle(seg, { create: true }); |
| 41 | const fh = await d.getFileHandle(parts[parts.length - 1], { create: true }); |
| 42 | const w = await fh.createWritable(); await w.write(body); await w.close(); |
| 43 | return true; |
| 44 | }, [p, body]); |
| 45 | |
| 46 | const readFile = (p) => s.page.evaluate(async (p) => { |
| 47 | const parts = p.split('/'); |
| 48 | let d = await navigator.storage.getDirectory(); |
| 49 | for (const seg of parts.slice(0, -1)) d = await d.getDirectoryHandle(seg); |
| 50 | const fh = await d.getFileHandle(parts[parts.length - 1]); |
| 51 | return await (await fh.getFile()).text(); |
| 52 | }, p).catch((e) => '(' + String(e).split('\n')[0] + ')'); |
| 53 | |
| 54 | // Seed g.txt = v1, then have the agent READ it (records its hash in read_seen). |
| 55 | await putFile(G, 'ORIGINAL-v1'); |
| 56 | const readOut = await chat(s, `@tool file_read {"path":"${G}"}`); |
| 57 | // THE READ HAS TO LAND. Nothing below means anything if the agent never saw the |
| 58 | // file: with no entry in `read_seen` a whole-file write is not stale, it is simply |
| 59 | // a write, and the refusal this test is about could not arise however broken the |
| 60 | // guard was. |
| 61 | check('the agent really read the file, so the guard has something to compare against', |
| 62 | /ORIGINAL-v1/.test(readOut) && !/Refused/.test(readOut), |
| 63 | readOut.slice(-160).replace(/\n/g, ' | ')); |
| 64 | |
| 65 | // Another agent changes g.txt underneath (simulated via OPFS directly). |
| 66 | await putFile(G, 'AGENT-B-WROTE-THIS'); |
| 67 | |
| 68 | // The first agent now writes a stale whole-file over it. |
| 69 | const out = await chat(s, `@tool file_write {"path":"${G}","content":"STALE-CLOBBER"}`); |
| 70 | const after = await readFile(G); |
| 71 | console.log('--- write-turn transcript tail ---\n' + out.slice(-300)); |
| 72 | console.log('file after stale write:', JSON.stringify(after)); |
| 73 | |
| 74 | check('GUARD REFUSED THE STALE WRITE', /changed on disk/.test(out), |
| 75 | out.slice(-200).replace(/\n/g, ' | ')); |
| 76 | check('AGENT B WORK PRESERVED — the other agent\'s bytes are still on disk', |
| 77 | after === 'AGENT-B-WROTE-THIS', JSON.stringify(after)); |
| 78 | // The other half, and the reason a refusal is not enough on its own: the guard |
| 79 | // must refuse THIS write and not writing in general. |
| 80 | const fresh = await chat(s, `@tool file_write {"path":"${dir}/h.txt","content":"UNRELATED"}`); |
| 81 | check('and a file this agent never read is still written — the guard refuses staleness, not writing', |
| 82 | await readFile(dir + '/h.txt') === 'UNRELATED', fresh.slice(-140).replace(/\n/g, ' | ')); |
| 83 | |
| 84 | const errs = errors(s).filter(e => !/502|Bad Gateway/.test(e)); |
| 85 | check('nothing threw', errs.length === 0, errs.slice(0, 2).join(' | ')); |
| 86 | |
| 87 | await s.close(); |
| 88 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 89 | if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); } |