From c0bf87b7898287c6ca9462a9157aded353567cdc Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 23 Oct 2026 14:20:42 -0500 Subject: [PATCH] Untested: row 288 library writes, notices, revert and human saves (#1793) --- BUILD-LOG.md | 3 + agents/bubba/work/queue-288/BUILD.md | 117 ++++++++++++ docs/SESSIONS.md | 1 + packages/bus/src/broker.mjs | 92 ++++++++- packages/bus/src/client.mjs | 2 +- packages/bus/src/runtime.mjs | 14 ++ packages/bus/src/server.mjs | 6 +- packages/bus/tests/library-write.test.mjs | 112 +++++++++++ packages/library/src/index.mjs | 2 + packages/library/src/mirror.mjs | 4 +- packages/library/src/sync.mjs | 39 +++- packages/library/src/write.mjs | 179 ++++++++++++++++++ packages/library/tests/write.test.mjs | 179 ++++++++++++++++++ packages/webui/src/cli.mjs | 6 +- packages/webui/src/console-bus.mjs | 3 +- .../tests/library-write-options.test.mjs | 22 +++ 16 files changed, 759 insertions(+), 22 deletions(-) create mode 100644 agents/bubba/work/queue-288/BUILD.md create mode 100644 packages/bus/tests/library-write.test.mjs create mode 100644 packages/library/src/write.mjs create mode 100644 packages/library/tests/write.test.mjs create mode 100644 packages/webui/tests/library-write-options.test.mjs diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 77720d7fa..e37dd676e 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -5589,3 +5589,6 @@ claim. Receipts and limitations in agents/lena/work/queue-296/BUILD.md and run-r 2026-10-25T04:36:47Z | Sage | Row 214 (#1715, C2-11a editor wikilink node, Herb) LANDED 3a8e4abad, pushed 04:27Z 10-25. Before: refactor at 78b69e1e6. After: Herb's six commits cherry-picked onto 78b69e1e6 and fast-forwarded. Gate GREEN 04:10:34Z: 18 package suites rc 0 (webui 471/471, cli 999 plus the allowed FED R7 skip), every scripts/test-*.sh rc 0, queue package via test-queue.sh 156. Design skips: queue 34 (not the canonical root), task 87/93 (row 244). Filbert APPROVE c31679 rev 4093. Range-diff equal except commit 5's BUILD-LOG context; 40/41 paths same blob, BUILD-LOG additions only. #1715 closed c31680. Queue note 4094, done 4095, committed ce8bb4d8b. 2026-10-25T04:36:47Z | Sage | Row 193 (#1691, threads user roles design, Morgana) LANDED 35f34ad27, doc-only: five design commits cherry-picked onto ce8bb4d8b, adding only agents/morgana/work/queue-193/DESIGN.md (verify-commit ok, 1 path). Duckworth APPROVE c31676 rev 4088 (posted and recorded by Sage; Duckworth has no Gitea credential). No test reads queue-193. Build rows from the design: 204/#1702, 205/#1703 (landed), 275/#1780, 206/#1704, 207/#1705, 208/#1706. 2026-10-25T04:58:30Z | Sage | Row 255 (#1760, Vikunja 458, bundled Pocket ID design for Q203, Launchpad) LANDED de3e23dde, doc-only: Launchpad's three design commits e29d8ec90, 9f60021a0 and c8f2bc723 cherry-picked onto 775bbe236 (range-diff equal), adding only agents/launchpad/work/queue-255/DESIGN.md. No test reads queue-255. Webby APPROVE round 1 c31689 rev 4103 (posted by Sage; Webby has no Gitea token). Mos rulings in mos-1666: M1 approved with conditions (rollback removes components before the pointer goes back and the STATE check confirms the key is absent; the loader stays strict inside the key; four test-config.sh cases), R1 chosen, M2 dropped. Sage gate check against Q203, O6, REQ-WEB-2 and REQ-INST-1: consistent. One PRD text gap goes to Mos: REQ-INST-2 says the stack writes the providers entry, and R1 prints it for the user to paste. Webby N1 and N2 bind P4; N3-N8 carry into the P1-P6 build rows, all post-v1. +## 2026-10-23 — Row 288 C2-02 build starts (Bubba, #1793) + +Before: agents can read the library; supporting-path and push-credential dependencies are separate unlanded heads. After intended: per-seat supporting writes/deletes with exact notices, human revert and one commit per Console save. Work is on bubba/row288, chained on original 283/285/286 commits; 284 tool base still pending. Plan and acceptance evidence: agents/bubba/work/queue-288/BUILD.md. Untested; no Node/test/build/gate run before Sage CLEAR. Landing waits for every dependency's own gate. diff --git a/agents/bubba/work/queue-288/BUILD.md b/agents/bubba/work/queue-288/BUILD.md new file mode 100644 index 000000000..e695db006 --- /dev/null +++ b/agents/bubba/work/queue-288/BUILD.md @@ -0,0 +1,117 @@ +# Row 288 (#1793): C2-02 agent writes, notices, revert and Console saves + +Owner: Bubba, Codex gpt-6.1-sol, thread 3d6672b5-5544-4b95-9e09-e79574f1aa14. +Reviewer: Webby. Gate and landing: Sage. Append-only build record. + +## 2026-10-23 start and implementation plan + +Base 45dca69f6 on bubba/row288. Direct mandate mos-1499 (Q245), confirmed sage-3613. +The canonical `scripts/mosaic queue next bubba` returned `nothing`; Sage owns the owner transition. +The initial worktree invocation refused linked-worktree use; retried in canonical checkout. + +Spec: docs/plans/2026-10-20_case2-rows.md, C2-02; agents/gosalyn/work/case2/DESIGN.md ruling after r11. +Handoffs: Honker queue-283 BUILD.md Q4 (missing/non-100644 delete refuses); Morgana +queue-225 BUILD.md A2 at 66f7becf2 (strict revert arguments/result), confirmed by Fethry fethry-0005. +`project` is the business.projects key, never a chat UUID. + +Dependencies merged with their commits intact, no amend or force-push: +- 283 origin/honker/row283-build 88ec5e8e4 (new E4 head to follow). +- 285 origin/scrooge/row285-r2 53db6b7ca. +- 286 origin/ludwig/row286-r2 573fa0be0. +- 284: no named remote branch yet. Tool changes come last. +All merge commits are marked Untested. Nothing has run under Node. Sage's latest instruction +sage-3613 reserves **all Node and test runs** for CLEAR; this supersedes the initial targeted-test +permission. Build untested now as instructed, then run RED/GREEN checks/mutants on CLEAR. + +Architecture: one host-only plumbing writer under the existing library lock; broker authorizes and +selects credentials, then inserts the fixed notice directly. Human save/revert use editor + +library-human independently of seat lifecycle. No worktree, deferred push, role expansion or UI. + +### Tasks and acceptance + +- [ ] Plumbing: new library/src/write.mjs, mirror state publication and package exports; new + library/tests/write.test.mjs. One-parent write/delete, byte-exact content, seat identity/trailer, + stale-base, nonregular/missing deletes, retry once on an unrelated remote change, push failure + without local ref movement, human editor and revert reachability/single-path checks. +- [ ] Broker + runtime: library.doc.write/delete, library.file.save, library.revert; strict keys, + creative + library.write + live unheld project grant; direct exact notice; human-only save/revert. + New bus/tests/library-write.test.mjs covers authorization, unchanged refs, A2 contract and socket. +- [ ] Transport: only human library.file.save joins the existing large-save exception. New tests + prove >64 KiB save accepted, agent writes retain 64 KiB bound, malformed/oversize saves refuse. +- [ ] Tools after 284: library_doc_write/delete in harness tools; exact eight/ten-name snapshots + in a new harness test; cli dispatch wiring. +- [ ] Verify on CLEAR: new tests, library/bus/harness suites and all scripts/test-*.sh on Sage's + assigned slot. Mutants remove supporting guard and force push; both must fail. Fixture-token + search over captured output. No gate, landing or queue move claimed before Sage's acceptance. + +Pre-flight interfaces: broker consumes library writer {project,path,commit}; revert adds reverted; +writer consumes C2-03b push {pushed} or {raced,tip} and supportingPath(mapping,universe,path,tree). +State publication must add optional lastPush while preserving old state readability. Refusals must +restore fetched remote-tracking refs as well as preserve served. Runtime must bind credential file +references itself; no file, identity, message or credential is supplied by the agent. + +Necessary file-scope additions: packages/bus/src/runtime.mjs (actual adapter injection), +packages/library/src/index.mjs (exports), and CLI's existing console adapter if saves require it. +Reported to Sage in bubba-0002. Existing tests will not be edited without Mos's OK via Sage. + +Review focus: remote tip races, nonregular entries and folded paths, unsafe human credential before +git, revert after revoked/fired writer, and large-request exception never extending agent writes. + +### Existing-test changes + +None authored by Bubba yet. Dependency test changes remain their owners' original commits. + +## 2026-10-23 first static build (not a gate result) + +283 E4 merged intact at 76d651dba. Honker next reports e67c27b32 (test environment only), to merge +before push. Della reports row 284 has not been built/pushed; row224's 23816cf28 is not a 284 head. +No speculative 284 pin. Typed tools and served-blob lookup integration remain pending 284. + +Implemented (unrun): bare plumbing writer, one-parent commit and fixed trailer, exact-content blobs, +base comparison, supporting path guard and delete regular-blob check, one remote-race retry, +ref restoration on refusal, immediate served publication/lastPush; human save and reachable one-path +business-trailer revert; broker grants/identity/notice path; runtime adapter wiring; large-save socket +exception. New tests: library/tests/write.test.mjs and bus/tests/library-write.test.mjs. No old test edits. + +### Approved scope additions, sage-3628 + +Every hunk outside the brief's owned list: +- `packages/bus/src/runtime.mjs`: `humanWriteOptions` checks editor before choosing the human slot; + three library adapter entries bind dataRoot, command and the host-only seat store reference. These + are needed to make actual bus calls reach the new writer without accepting agent-supplied files. +- `packages/library/src/index.mjs`: export writeLibraryDoc/saveLibraryFile/revertLibraryCommit so + runtime's existing library module adapter can call them. +- `packages/library/src/sync.mjs`: shared write guard with opaque token compared by identity; + push's race fetch recognizes only that owner and does not clear its guard; ordinary sync and a + forged token still refuse library-sync-busy. Narrow publishLibraryWrite validates ids, reads the + locked state, rebuilds the index via readLibrary, moves served/tracking and writes lastPush. The + raw writeState remains private and is not exported through index.mjs. + +Ludwig ludwig-0064 reviewed the intended interface and requested the opaque token (implemented), +narrow publication (implemented), whole library + bus library-credential checks and a token mutant +(planned). His 573fa0be0 remains current and unrun. 288's additions are separate commits, not folded +into 286. Tracking restores only if the whole write refuses, not between the first race and retry. + +Known tradeoff, accepted by Sage sage-3628 for MVP: the brief holds the flock through network Git; +readers can hit the existing 5-second library-sync-busy while it runs. Structural supportingPath +preflight is pure and precedes Git; served/fetched listTree checks repeat it with the real tree before +any remote write. A tree-dependent check necessarily reads the tree first (Honker honker-0184 item2). + +### Mutants for CLEAR + +- Remove the `pathOf` calls for agent operations: the M12 broker/bare-ref tests must fail on a + story/unsupported path while checking remote, tracking and served refs. +- Prefix the push refspec in syncCommand with `+`: write.test moved-target race must fail, because + its command seam preserves production syncCommand's refspec (only remote URL/protocol are fixture). +- Replace `writers.get(mirror) === writeToken` with `true`: write.test forged-token case must fail. + +No Node, test, build or mutant has run. `git diff --check` is clean. Counts are not claimed. + +### Console dispatch integration + +The existing cli delegates its transport wiring to console-bus.mjs, so the save dispatch needs that +file too: one null `librarySave` option and one `--send` assignment to library.file.save. cli.mjs +threads that option into startServer; row C2-11b owns the actual route/page that consumes it. New +webui/tests/library-write-options.test.mjs covers --send and the user transport boundary. 225 owns +libraryRevert dispatch; 288 adds only librarySave to avoid overlapping that ownership. Additional +scope reported to Sage and Fethry. No changes to an existing test. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index f1ef08d63..da486c901 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1696,3 +1696,4 @@ are never rewritten or removed; corrections are new entries. 2026-10-25 | Sage 1ef1e4f8 | row284 landing #1789 | Gate GREEN 00:09:04Z unchanged, landed ce0b6ed41 pushed 00:09:52Z; #1789 closed c31655; queue note 4071, done 4072 (7c8dc2033); DEFERRED Webby N1 to the next library read-path row; row 332 PROXY_CODEX_API ruled openai-responses (sage-4331), P3 holds for row 331. 2026-10-25 | Sage 1ef1e4f8 | rows 214 and 193 landing (#1715, #1691) | 214 gate GREEN 04:10:34Z, landed 3a8e4abad, #1715 closed c31680, queue 4094/4095 (ce8bb4d8b); 193 doc-only landed 35f34ad27 after Duckworth approve 4088; 198 group A and 267 assertion diffs to Mos (sage-4397, sage-4403); CLEAR to Bubba for 288. 2026-10-25 | Sage 1ef1e4f8 | row255 landing #1760 | Doc-only design landed de3e23dde after Webby approve 4103 (c31689); 288 r6 gate green from Bubba (2604/0, 12 scripts, 6 mutants), in-review move handed back to the claimant. +2026-10-23 | Bubba (Codex gpt-6.1-sol, 3d6672b5) | Row 288 / #1793 C2-02 in bubba/row288; mos-1499, sage-3613 | Started; 283/285/286 merged intact, static build pending Sage CLEAR; queue ownership remains with Sage. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 338a52dbc..0dc9479f2 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -110,7 +110,7 @@ const LIBRARY_LOOKUPS = Object.freeze({ 'library.manuscript': { method: 'manuscript', fields: ['universe', 'book', 'scope', 'chapter', 'from', 'to'], required: ['universe', 'book'] }, 'library.universe': { method: 'universeInfo', fields: ['universe'], required: ['universe'] }, }); -export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync', ...Object.keys(LIBRARY_LOOKUPS)]); +export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync', 'library.file.save', 'library.revert', 'library.doc.write', 'library.doc.delete', ...Object.keys(LIBRARY_LOOKUPS)]); export const PROJECT_SAVE_VERB = 'project.file.save'; export const PROJECT_SAVE_REQUEST_MAX = 1024 * 1024 + 64 * 1024; export const WEB_VERBS =Object.freeze(['web.search', 'web.fetch']); @@ -5411,18 +5411,91 @@ export class Broker { } // C2-04 M4: only a verified human reads library text or status. Host Git // functions stay injected, preserving this module's sandbox import closure. + // C2-02: all authority and credential choice stays on the host. Neither an agent's tool + // arguments nor the human's save may supply a credential, identity or commit message. + #libraryWriter(s, project) { + if (s.tester) fail('tester-refused'); + const b = this.#business(s.business), role = b.roles?.[s.role]; + if (s.human || s.reader || !CREATIVE_DEFINITIONS.includes(role?.definition) + || !role.authority?.withinRole?.includes('library.write')) fail('library-write-refused'); + this.#agent(s); + const live = this.#store.get('SELECT user,projects,rotate_by FROM gitea_grants WHERE business=? AND agent=?', s.business, s.role); + if (!live || !this.#giteaKeys || this.#giteaKeys.held(s.business, s.role) + || this.#firing(s.business, s.role) || live.rotate_by < this.#now().slice(0, 10) + || !role.publish?.includes(project)) fail('library-write-no-grant'); + let projects; + try { projects = JSON.parse(live.projects); } catch { fail('library-write-no-grant'); } + if (!Array.isArray(projects) || !projects.includes(project) || live.user !== role.identity?.gitea?.user) + fail('library-write-no-grant'); + if (!role.identity?.name || !role.identity?.email) fail('library-write-refused'); + return { identity: { name: role.identity.name, email: role.identity.email }, + agent: `${s.business}/${s.role}`, credential: { kind: 'seat', agent: s.role, user: live.user } }; + } + #libraryWriteNotice(s, op, result) { + const mid = randomUUID(); + this.#store.run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)', + mid, this.#now(), s.business, s.role, s.run, 'human', 'INFO', null, null, null, + JSON.stringify({ notice: 'library-write', op, path: result.path, commit: result.commit })); + this.#route(s.business, mid, 'human', s.session); + } async #requestLibrary(s, request) { const { verb, args } = request; if (Object.hasOwn(LIBRARY_LOOKUPS, verb)) return this.#requestLibraryLookup(s, request); try { - this.#human(s); - const fields = verb === 'library.file' ? ['project', 'path'] : verb === 'library.sync' ? ['project', 'accept'] : ['project']; - keys(args, fields, verb === 'library.sync' ? ['project'] : fields); - if (!this.#library?.readFile || !this.#library?.status) fail('project-not-configured'); - if (verb === 'library.sync' && !this.#library.sync) fail('project-not-configured'); - const context = { business: this.#business(s.business), project: args.project }; + const agent = verb === 'library.doc.write' || verb === 'library.doc.delete'; + const b = this.#business(s.business); + let project = args.project, writer; + if (agent) { + // The caller gate precedes argument checks, including human and tester refusals. + if (s.tester) fail('tester-refused'); + if (s.human || s.reader || !CREATIVE_DEFINITIONS.includes(b.roles?.[s.role]?.definition) + || !b.roles[s.role].authority?.withinRole?.includes('library.write')) fail('library-write-refused'); + this.#agent(s); + const fields = ['universe', 'path', 'base', ...(verb === 'library.doc.write' ? ['content'] : [])]; + keys(args, fields, fields); + if (typeof args.universe !== 'string') fail('invalid-request'); + const matches = Object.entries(b.projects ?? {}).filter(([, e]) => e.kind === 'library' + && Object.hasOwn(e.universes ?? {}, args.universe)); + if (matches.length !== 1) fail('project-not-configured'); + project = matches[0][0]; + writer = this.#libraryWriter(s, project); + } else { + this.#human(s); + const fields = verb === 'library.file' ? ['project', 'path'] : verb === 'library.sync' ? ['project', 'accept'] + : verb === 'library.file.save' ? ['project', 'path', 'base', 'content'] + : verb === 'library.revert' ? ['project', 'commit'] : ['project']; + keys(args, fields, verb === 'library.sync' ? ['project'] : fields); + } + if (typeof project !== 'string' || b.projects?.[project]?.kind !== 'library') fail('project-not-configured'); + if (verb === 'library.revert' && (typeof args.commit !== 'string' || !/^[a-f0-9]{40}$/.test(args.commit))) fail('invalid-request'); + if (agent || verb === 'library.file.save') { + if (args.base !== null && (typeof args.base !== 'string' || !/^[a-f0-9]{40}$/.test(args.base))) fail('invalid-request'); + if (verb !== 'library.doc.delete' && (typeof args.content !== 'string' || args.content.includes('\0') + || Buffer.byteLength(args.content, 'utf8') > 1024 * 1024 + || Buffer.from(args.content, 'utf8').toString('utf8') !== args.content)) fail('invalid-request'); + } + const context = { business: b, project }; let result; - if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path }); + const method = agent ? 'writeDoc' : verb === 'library.file.save' ? 'saveFile' + : verb === 'library.revert' ? 'revert' : verb === 'library.file' ? 'readFile' : verb === 'library.status' ? 'status' : 'sync'; + if (typeof this.#library?.[method] !== 'function') fail('project-not-configured'); + if (agent) { + const op = verb === 'library.doc.delete' ? 'delete' : 'write'; + result = await this.#library.writeDoc({ ...context, ...args }, { ...writer, op, + beforePush: () => { this.#processingWork(); this.#libraryWriter(s, project); } }); + try { + this.#store.transaction(() => { + this.#libraryWriteNotice(s, op, result); + this.#event(s, 'action.allowed', { action: 'library.write', class: 'within-role', project, path: result.path, commit: result.commit }); + }); + } catch { fail('outcome-unknown'); } + } else if (verb === 'library.file.save') { + this.#processingWork(); + result = await this.#library.saveFile({ ...context, path: args.path, base: args.base, content: args.content }); + } else if (verb === 'library.revert') { + this.#processingWork(); + result = await this.#library.revert({ ...context, commit: args.commit }); + } else if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path }); else if (verb === 'library.status') { result = await this.#library.status(context); // C2-06: the reply limits in force, read from the file now, and the keys that fell back. @@ -5432,10 +5505,9 @@ export class Broker { } } else { - // A fetch that did not move served still answers with the code, so the refusal is recorded. const { state, code } = await this.#library.sync(context, { accept: args.accept ?? null }); if (code !== null) fail(code); - result = { project: args.project, ...state }; + result = { project, ...state }; } this.#secretCheck(result); return result; diff --git a/packages/bus/src/client.mjs b/packages/bus/src/client.mjs index 6e06e771e..95577f52b 100644 --- a/packages/bus/src/client.mjs +++ b/packages/bus/src/client.mjs @@ -17,7 +17,7 @@ export class Client { verb, args, }) + '\n'; - if (Buffer.byteLength(request) > (verb === PROJECT_SAVE_VERB ? PROJECT_SAVE_REQUEST_MAX : 65536)) + if (Buffer.byteLength(request) > ([PROJECT_SAVE_VERB, 'library.file.save'].includes(verb) ? PROJECT_SAVE_REQUEST_MAX : 65536)) return Promise.reject(new BusError('request-too-large')); return new Promise((resolve, reject) => { const socket = connect(this.path); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 342314b94..29113616b 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -281,6 +281,12 @@ export async function startBroker({ credential: fetchCredential(business), ...(librarySync ? { command: librarySync.command } : {}), }); + const humanWriteOptions = context => { + const mapping = libraryModule.checkMapping(context.business.projects[context.project]); + if (!mapping.editor) throw new libraryModule.LibraryError('library-mapping-invalid', 'editor is required'); + return { credential: libraryModule.humanCredential(context, { user: mapping.user, configDir: slotDir }), + ...(librarySync ? { command: librarySync.command } : {}) }; + }; // Row 194 (#1692, D3): after every write that commits, the broker takes the business as a restart // would load it. The credential references, the project roots the Credentials store protects // and the tracker bots are fixed at start (the Credentials store, the tasks adapter), so a file @@ -359,6 +365,14 @@ export async function startBroker({ readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }), status: context => libraryModule.libraryStatus({ ...context, dataRoot }), sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })), + // C2-02: trusted references only. The askpass helper alone opens a credential file. + writeDoc: (context, options) => processing.work(() => libraryModule.writeLibraryDoc({ ...context, dataRoot }, { + ...options, credential: { ...options.credential, + file: storeRoot ? giteaReference(storeRoot, context.business.id, options.credential.agent).file : null }, + ...(librarySync ? { command: librarySync.command } : {}), + })), + saveFile: context => processing.work(() => libraryModule.saveLibraryFile({ ...context, dataRoot }, humanWriteOptions(context))), + revert: context => processing.work(() => libraryModule.revertLibraryCommit({ ...context, dataRoot }, humanWriteOptions(context))), LibraryError: libraryModule.LibraryError, // C2-06: the citation guard, the reply limits read from the business file at each check (none // configured reads as unreadable, so requireCitation is true), and thread.messages' `last`. diff --git a/packages/bus/src/server.mjs b/packages/bus/src/server.mjs index 3e1d0441a..a55ad7c7e 100644 --- a/packages/bus/src/server.mjs +++ b/packages/bus/src/server.mjs @@ -6,7 +6,9 @@ import { createPeerReader } from './peercred.mjs'; const LIMIT = 65536; // Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near // its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below. -const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`); +const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`); +const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/; +const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) }; // The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which // only the citation guard's refusals do, so every other envelope is byte-identical to before. const refusal = (e, other) => e instanceof BusError @@ -66,7 +68,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica try { if (end !== input.length - 1) throw new BusError('invalid-envelope'); const r = JSON.parse(input.subarray(0, end).toString('utf8')); - if (input.length > LIMIT && r?.verb !== PROJECT_SAVE_VERB) throw new BusError('request-too-large'); + if (input.length > LIMIT && ![PROJECT_SAVE_VERB, 'library.file.save'].includes(r?.verb)) throw new BusError('request-too-large'); if ( !r || Array.isArray(r) || diff --git a/packages/bus/tests/library-write.test.mjs b/packages/bus/tests/library-write.test.mjs new file mode 100644 index 000000000..1859d8129 --- /dev/null +++ b/packages/bus/tests/library-write.test.mjs @@ -0,0 +1,112 @@ +// Row 288: broker authority, exact notice and strict Console revert API. Fixture grant rows only. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Store } from '../src/store.mjs'; +import { Broker } from '../src/broker.mjs'; +import { Client } from '../src/client.mjs'; +import { serve } from '../src/server.mjs'; +import { mappingEntry } from '../../library/tests/helpers.mjs'; +import { LibraryError } from '../../library/src/problems.mjs'; +import { grantDecision } from './host-fixtures.mjs'; + +const SHA = 'a'.repeat(40), NEXT = 'b'.repeat(40), PATH = 'Book One/Notes/Agent.md'; +function world(t) { + const root = mkdtempSync(join(tmpdir(), 'bus-library-write-')), store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const projects = { books: mappingEntry() }; projects.books.universes.harbor.supporting = ['Book One/Notes/']; + const authority = { withinRole: ['message.send', 'library.write'], crossRole: [] }; + const roles = { bean: { definition: 'companion', authority, identity: { name: 'Bean', email: 'bean@example.test', gitea: { user: 'bean', url: 'https://git.example.invalid' } }, publish: ['books'] }, + chief: { definition: 'chief-of-staff', authority }, quill: { definition: 'proofreader', authority: { withinRole: ['message.send'], crossRole: [] } } }; + const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true }, projects, roles, + arbiters: { technical: 'chief', delivery: 'chief' }, definitions: { companion: {}, 'chief-of-staff': {}, proofreader: {} } }; + const seen = [], held = new Set(); + const result = { project: 'books', path: PATH, commit: NEXT }; + const library = { LibraryError, readFile: async () => ({}), status: async () => ({}), + writeDoc: async (context, options) => { seen.push({ context, options }); options.beforePush(); return result; }, + saveFile: async context => { seen.push({ context }); return result; }, + revert: async context => { seen.push({ context }); return { ...result, reverted: context.commit }; } }; + const broker = new Broker({ store, businesses: { acme: business }, library, giteaKeys: { held: (b, a) => held.has(a) } }); + const caps = { human: broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }), + tester: broker.bindTester({ business: 'acme' }) }; + const p = broker.request(caps.human, { verb: 'project.create', args: { name: 'Library chat', repo: 'books' } }).id; + for (const role of ['bean', 'quill', 'chief']) { + const thread = role === 'chief' ? null : broker.request(caps.human, + { verb: 'thread.create', args: { project: p, name: role, instance: role } }).id; + caps[role] = broker.bindLaunch({ business: 'acme', role, run: role + '-run', harness: 'pi', ...(thread ? { thread } : {}) }); + broker.request(caps[role], { verb: 'role.claim' }); + } + const grant = (project = 'books') => { + const decision = grantDecision(store, { business: 'acme', grant: { method: 'gitea', agent: 'bean', target: { projects: [project] }, rotateBy: '2099-01-01' } }); + store.run('INSERT OR REPLACE INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?)', + 'acme', 'bean', 'bean', 7, 'fixture', JSON.stringify([project]), '2099-01-01', decision); + }; + grant(); + const call = (who, verb, args = {}) => Promise.resolve().then(() => broker.request(caps[who], { verb, args })); + return { root, store, broker, caps, call, seen, held, business, grant, library }; +} +const args = () => ({ universe: 'harbor', path: PATH, base: SHA, content: 'fixture\n' }); + +test('C2-02: agent verbs require creative holder, action and live unheld project grant', async t => { + const f = world(t); + for (const verb of ['library.doc.write', 'library.doc.delete']) { + const a = args(); if (verb.endsWith('delete')) delete a.content; + for (const [who, code] of [['human', 'library-write-refused'], ['tester', 'tester-refused'], ['chief', 'library-write-refused'], ['quill', 'library-write-refused']]) + await assert.rejects(f.call(who, verb, a), { code }); + f.held.add('bean'); await assert.rejects(f.call('bean', verb, a), { code: 'library-write-no-grant' }); f.held.clear(); + f.store.run('DELETE FROM gitea_grants'); + await assert.rejects(f.call('bean', verb, a), { code: 'library-write-no-grant' }); + f.grant('other'); await assert.rejects(f.call('bean', verb, a), { code: 'library-write-no-grant' }); + f.store.run('DELETE FROM gitea_grants'); f.grant(); + } + assert.equal(f.seen.length, 0); +}); + +test('C2-02: writing instance supplies neither ident nor notice text; broker inserts exact notice', async t => { + const f = world(t); + const result = await f.call('bean', 'library.doc.write', args()); + assert.equal(result.commit, NEXT); + const m = f.store.get("SELECT * FROM messages WHERE from_role='bean' AND to_role='human'"); + assert.equal(m.body, JSON.stringify({ notice: 'library-write', op: 'write', path: PATH, commit: NEXT })); + assert.equal(m.from_run, 'bean-run'); + assert.ok(f.store.get('SELECT 1 FROM message_threads WHERE message=?', m.id)); + assert.equal(f.seen[0].options.identity.name, 'Bean'); + assert.equal(f.seen[0].options.credential.user, 'bean'); + await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), author: 'other' }), { code: 'invalid-request' }); +}); + +test('C2-02 A2: strict human revert args/result and agent human-required refusal', async t => { + const f = world(t); + for (const who of ['bean', 'quill', 'chief', 'tester']) + for (const verb of ['library.revert', 'library.file.save']) + await assert.rejects(f.call(who, verb, {}), { code: 'human-required' }); + for (const a of [{ commit: SHA }, { project: 'books' }, { project: 'books', commit: 'A'.repeat(40) }, + { project: 'books', commit: SHA, editor: {} }, { project: 'books', commit: SHA + 'a' }]) + await assert.rejects(f.call('human', 'library.revert', a), { code: 'invalid-request' }); + assert.deepEqual(await f.call('human', 'library.revert', { project: 'books', commit: SHA }), + { project: 'books', path: PATH, reverted: SHA, commit: NEXT }); + await assert.rejects(f.call('human', 'library.revert', { project: 'missing', commit: SHA }), { code: 'project-not-configured' }); +}); + +test('C2-02: human revert independent of revoked seat and LibraryError codes survive', async t => { + const f = world(t); f.store.run('DELETE FROM gitea_grants'); f.held.add('bean'); + assert.equal((await f.call('human', 'library.revert', { project: 'books', commit: SHA })).reverted, SHA); + for (const code of ['library-revert-not-agent', 'stale-base', 'library-mapping-invalid', 'library-human-missing']) { + f.library.revert = async () => { throw new LibraryError(code, 'fixture'); }; + await assert.rejects(f.call('human', 'library.revert', { project: 'books', commit: SHA }), { code }); + } +}); + +test('C2-02 transport: human library.file.save exceeds 64 KiB; agent write remains bounded', async t => { + const f = world(t), path = join(f.root, 'bus.sock'); + const server = await serve({ broker: f.broker, path, authenticateHuman: () => f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }) }); + t.after(() => server.close()); + const human = new Client({ path, human: { fixture: true } }), agent = new Client({ path, cap: f.caps.bean }); + const content = 'x'.repeat(70000); + assert.equal((await human.call('library.file.save', { project: 'books', path: PATH, base: SHA, content })).commit, NEXT); + assert.equal(f.seen.at(-1).context.content, content); + await assert.rejects(agent.call('library.doc.write', { ...args(), content }), { code: 'request-too-large' }); + await assert.rejects(human.call('library.file.save', { project: 'books', path: PATH, base: SHA, content: 'x'.repeat(2 * 1024 * 1024) }), { code: 'request-too-large' }); +}); diff --git a/packages/library/src/index.mjs b/packages/library/src/index.mjs index 4209f7582..79d3b83a3 100644 --- a/packages/library/src/index.mjs +++ b/packages/library/src/index.mjs @@ -13,3 +13,5 @@ export { buildIndex, indexFor, clearIndexCache, indexCacheSize } from './index-b export { sees, find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from './query.mjs'; export { REPLY_LIMITS, resolveReplyLimits, checkReplyLimits } from './limits.mjs'; export { GUARD_CODES, FIXED_TEXTS, WEB_CITE_DEFINITIONS, REASON_MAX, GuardError, parseReply, checkReply, libraryReader, guardReply, lastCommits } from './guard.mjs'; + +export { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from './write.mjs'; diff --git a/packages/library/src/mirror.mjs b/packages/library/src/mirror.mjs index bad515ced..e02a5b4bc 100644 --- a/packages/library/src/mirror.mjs +++ b/packages/library/src/mirror.mjs @@ -221,7 +221,9 @@ async function readStateHeld(mirror, { branch } = {}) { finally { if (fd !== undefined) closeSync(fd); } // C2-03b adds `credential`, { kind, user } or null. It is optional, so a state from before it reads. const listed = ['version', 'branch', 'served', 'lastFetch', 'lastError', 'diverged', 'problems']; - if (!keys(value, Object.hasOwn(value ?? {}, 'credential') ? [...listed, 'credential'] : listed) + const expected = [...listed, ...['credential', 'lastPush'].filter(key => Object.hasOwn(value ?? {}, key))]; + if (!keys(value, expected) + || !optional(value.lastPush ?? null, v => keys(v, ['commit', 'at']) && SHA.test(v.commit) && utc(v.at)) || !optional(value.credential ?? null, v => keys(v, ['kind', 'user']) && ['slot', 'chief', 'seat', 'human'].includes(v.kind) && typeof v.user === 'string' && /^[A-Za-z0-9_.-]{1,100}$/.test(v.user)) || value.version !== 1 || value.branch !== branch || value.served !== served diff --git a/packages/library/src/sync.mjs b/packages/library/src/sync.mjs index df52dbce7..5277f723c 100644 --- a/packages/library/src/sync.mjs +++ b/packages/library/src/sync.mjs @@ -32,6 +32,15 @@ const fail = (code, message = code) => { throw new LibraryError(code, message); // One sync per mirror in this process. The library lock covers publication only, so a second // fetch into the same mirror is refused here rather than racing the first. const running = new Set(); +// C2-02: opaque identity lets only the owning write re-enter push's race fetch. +const writers = new Map(); +export async function withLibraryWriteGuard(mirror, operation) { + if (running.has(mirror)) fail('library-sync-busy'); + const token = Object.freeze({}); + running.add(mirror); writers.set(mirror, token); + try { return await operation(token); } + finally { writers.delete(mirror); running.delete(mirror); } +} // The one command builder for her remote. `kind` is 'fetch' or 'push'. A push sends `commit` to the // branch with no '+', so the remote takes a fast-forward only (D6). `slot` is the file the helper reads. @@ -210,7 +219,7 @@ export async function sync(context, { slot, credential = null, accept = null, co // mirror runs. A hook refusal ([remote rejected]) or any other failure // refuses library-push-failed, and git's output is never kept. It moves no local ref: C2-02 moves the // remote-tracking and served refs after a push. -export async function push(context, { commit, credential, retried = false, command = syncCommand, limits = GIT_LIMITS } = {}) { +export async function push(context, { commit, credential, retried = false, writeToken = null, command = syncCommand, limits = GIT_LIMITS } = {}) { const mirror = await ensureMirror(context); const mapping = checkMapping(context.business.projects[context.project]); const { branch } = mapping; @@ -233,8 +242,9 @@ export async function push(context, { commit, credential, retried = false, comma if (retried) fail('stale-base', 'her branch moved again during the write'); // The race fetch writes the mirror, so it takes sync's guard (dewey-0864 R1). A held guard refuses // library-sync-busy and the caller tries the write again. - if (running.has(mirror)) fail('library-sync-busy', 'a sync of this library is running'); - running.add(mirror); + const owned = writeToken !== null && writers.get(mirror) === writeToken; + if (running.has(mirror) && !owned) fail('library-sync-busy', 'a sync of this library is running'); + if (!owned) running.add(mirror); try { const fetch = command('fetch', mapping.remote, opts); if (!(await runGit(fetch.argv, fetch.env, limits)).ok) fail('library-push-failed', 'fetch after a moved tip failed'); @@ -243,5 +253,26 @@ export async function push(context, { commit, credential, retried = false, comma catch { fail('library-push-failed', 'fetch after a moved tip found no branch'); } if (!SHA.test(tip)) fail('library-push-failed', 'fetch after a moved tip found no branch'); return { pushed: false, raced: true, tip }; - } finally { running.delete(mirror); } + } finally { if (!owned) running.delete(mirror); } +} + +// Called under the publication lock by C2-02; the raw state writer stays private. +export async function publishLibraryWrite(mirror, { mapping, commit, previous, readSyncState, now = () => new Date() }) { + if (!SHA.test(commit) || (previous !== null && !SHA.test(previous))) fail('library-state-invalid'); + const state = await readSyncState({ branch: mapping.branch }); + if (state.diverged !== null) fail('library-diverged'); + if (state.served !== previous) fail('library-state-invalid'); + const read = await readLibrary({ repo: mirror, commit, mapping }); + const at = now().toISOString(); + const next = { ...state, served: commit, lastPush: { commit, at }, lastError: null, + problems: read.problems.map(p => ({ ...p })) }; + const tracking = `refs/remotes/library/${mapping.branch}`; + // The remote push already succeeded. A local publication failure is outcome-unknown to the + // caller; it must never encourage an automatic second push. + try { + await git(mirror, ['update-ref', 'refs/heads/served', commit, previous ?? ZERO]); + await git(mirror, ['update-ref', tracking, commit]); + writeState(mirror, next); + } catch { fail('outcome-unknown'); } + return next; } diff --git a/packages/library/src/write.mjs b/packages/library/src/write.mjs new file mode 100644 index 000000000..1b1dc5c8c --- /dev/null +++ b/packages/library/src/write.mjs @@ -0,0 +1,179 @@ +// C2-02: one-path plumbing commits on the private bare mirror. No checkout and no pending push. +import { lstatSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { SAFE, git, gitBytes } from '../../project/src/git.mjs'; +import { gitEnv, runGit, GIT_LIMITS } from '../../seat/src/checkout.mjs'; +import { checkMapping } from './mapping.mjs'; +import { classify } from './scope.mjs'; +import { supportingPath } from './supporting.mjs'; +import { LibraryError } from './problems.mjs'; +import { listTree, MAX_BLOB } from './reader.mjs'; +import { mirrorPath, ensureMirror, servedCommit, withLibraryLock } from './mirror.mjs'; +import { syncCommand, push, withLibraryWriteGuard, publishLibraryWrite } from './sync.mjs'; + +const SHA = /^[a-f0-9]{40}$/; +const fail = code => { throw new LibraryError(code, code); }; +const ident = identity => ({ GIT_AUTHOR_NAME: identity.name, GIT_AUTHOR_EMAIL: identity.email, + GIT_COMMITTER_NAME: identity.name, GIT_COMMITTER_EMAIL: identity.email }); + +function mappingOf(context) { + if (!Object.hasOwn(context.business?.projects ?? {}, context.project)) fail('project-not-configured'); + return checkMapping(context.business.projects[context.project]); +} +function contentOf(value) { + if (typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value, 'utf8') > MAX_BLOB + || Buffer.from(value, 'utf8').toString('utf8') !== value) fail('invalid-request'); + return value; +} +function credentialOf(credential, human) { + const code = human ? 'library-human-missing' : 'library-write-no-grant'; + if (!credential || credential.kind !== (human ? 'human' : 'seat') + || typeof credential.user !== 'string' || !/^[A-Za-z0-9_.-]{1,100}$/.test(credential.user) + || typeof credential.file !== 'string') fail(code); + try { + const st = lstatSync(credential.file); + if (!st.isFile() || st.uid !== process.getuid() || st.nlink !== 1 || (st.mode & 0o777) !== 0o600 || st.size > 16384) fail(code); + } catch { fail(code); } + return credential; +} +// Her save uses the same structural and folded-tree checks; only the supporting rule changes. +function pathOf(mapping, universe, path, tree, human) { + if (!human) return supportingPath(mapping, universe, path, tree); + if (typeof path !== 'string' || classify(mapping, path).scope === 'unmapped') fail('library-write-story-path'); + const place = classify(mapping, path); + const universes = Object.fromEntries(Object.keys(mapping.universes).map(id => [id, + { ...mapping.universes[id], supporting: id === place.universe ? [path.slice(0, path.lastIndexOf('/') + 1)] : [] }])); + return supportingPath({ ...mapping, universes }, place.universe, path, tree); +} +async function ref(mirror, name) { + const rows = await git(mirror, ['for-each-ref', '--format=%(refname) %(objectname)', name]); + const line = rows.split('\n').find(row => row.startsWith(name + ' ')); + return line ? line.slice(name.length + 1) : null; +} +async function ancestor(mirror, older, newer) { + const result = await runGit([...SAFE, '-C', mirror, 'merge-base', '--is-ancestor', older, newer], gitEnv()); + if (result.ok) return true; + if (result.why === 'exit 1') return false; + fail('library-git'); +} +async function fetchTip(mirror, mapping, credential, command) { + const c = command('fetch', mapping.remote, { mirror, branch: mapping.branch, user: credential.user, slot: credential.file }); + if (!(await runGit(c.argv, c.env)).ok) fail('library-push-failed'); + const tip = await ref(mirror, `refs/remotes/library/${mapping.branch}`); + if (!tip || !SHA.test(tip)) fail('library-push-failed'); + return tip; +} + +async function plumbing(mirror, tip, path, content, identity, message, stage) { + const env = { ...gitEnv(), ...ident(identity), GIT_INDEX_FILE: join(stage, 'index'), GIT_CEILING_DIRECTORIES: dirname(mirror) }; + const run = async (args, input = null) => { + const result = await runGit([...SAFE, '-c', 'core.createObject=rename', '-C', mirror, ...args], env, GIT_LIMITS, { stdin: input }); + if (!result.ok) fail('library-git'); + return result.out.trimEnd(); + }; + rmSync(env.GIT_INDEX_FILE, { force: true }); + await run(['read-tree', tip]); + if (content === null) await run(['update-index', '--force-remove', '--', path]); + else { + const file = join(stage, 'content'); writeFileSync(file, content, { mode: 0o600 }); + const blob = await run(['hash-object', '-w', '--no-filters', file]); + // --cacheinfo keeps the path a separate argv item; no pathspec expansion or filters. + await run(['update-index', '--add', '--cacheinfo', '100644', blob, path]); + } + const tree = await run(['write-tree']); + return run(['commit-tree', tree, '-p', tip, '-m', message]); +} + +async function revertChange(mirror, served, context, mapping) { + const commit = context.commit; + const refuse = () => fail('library-revert-not-agent'); + if (!served || !SHA.test(commit)) refuse(); + let text, parents; + try { + if (!await ancestor(mirror, commit, served)) refuse(); + text = await git(mirror, ['show', '-s', '--format=%(trailers:key=Mosaic-Agent,valueonly)', commit]); + parents = (await git(mirror, ['show', '-s', '--format=%P', commit])).split(' '); + } catch { refuse(); } + if (parents.length !== 1 || !SHA.test(parents[0]) + || !new RegExp(`^${context.business.id}/[a-z][a-z0-9-]*$`).test(text)) refuse(); + const before = await listTree(mirror, parents[0]), after = await listTree(mirror, commit); + const old = new Map(before.map(e => [e.path, e])), current = new Map(after.map(e => [e.path, e])); + const changed = [...new Set([...old.keys(), ...current.keys()])].filter(path => + old.get(path)?.oid !== current.get(path)?.oid || old.get(path)?.mode !== current.get(path)?.mode); + if (changed.length !== 1) refuse(); + const path = changed[0], was = old.get(path), made = current.get(path); + if ((was && (was.mode !== '100644' || was.type !== 'blob' || was.size > MAX_BLOB)) + || (made && (made.mode !== '100644' || made.type !== 'blob'))) refuse(); + pathOf(mapping, null, path, after, true); + const bytes = was ? await gitBytes(mirror, ['cat-file', 'blob', was.oid], MAX_BLOB) : null; + let content = null; + if (bytes !== null) { + try { content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { refuse(); } + contentOf(content); + } + return { path, base: made?.oid ?? null, content, reverted: commit }; +} + +async function write(context, options, human, revert = false) { + const mapping = mappingOf(context); + if (human && !mapping.editor) fail('library-mapping-invalid'); + const credential = credentialOf(options.credential, human); + const identity = human ? mapping.editor : options.identity; + if (!identity || typeof identity.name !== 'string' || typeof identity.email !== 'string') fail('library-write-refused'); + if (!human && (typeof options.agent !== 'string' || !options.agent.startsWith(context.business.id + '/'))) fail('library-write-refused'); + const op = options.op ?? 'write'; + if (!['write', 'delete'].includes(op)) fail('invalid-request'); + let change = revert ? null : { path: context.path, base: context.base, content: op === 'delete' ? null : contentOf(context.content) }; + if (change) { + if (change.base !== null && (typeof change.base !== 'string' || !SHA.test(change.base))) fail('invalid-request'); + pathOf(mapping, context.universe, change.path, [], human); + } + const mirror = mirrorPath(context); + return withLibraryWriteGuard(mirror, writeToken => withLibraryLock(mirror, async lock => { + await ensureMirror(context); + const state = await lock.readSyncState({ branch: mapping.branch }); + if (state.diverged !== null) fail('library-diverged'); + const served = state.served; + if (!served) fail('library-commit'); + if (revert) change = await revertChange(mirror, served, context, mapping); + // Check the served tree before any remote effect, then recheck against every fetched tip. + pathOf(mapping, context.universe, change.path, await listTree(mirror, served), human); + const tracking = `refs/remotes/library/${mapping.branch}`, previous = await ref(mirror, tracking); + let pushed = false; + const stage = mkdtempSync(join(dirname(mirror), '.library-write-')); + try { + let tip = await fetchTip(mirror, mapping, credential, options.command ?? syncCommand); + for (let attempt = 0; attempt < 2; attempt++) { + if (!await ancestor(mirror, served, tip)) fail('library-diverged'); + const tree = await listTree(mirror, tip); + pathOf(mapping, context.universe, change.path, tree, human); + const entry = tree.find(e => e.path === change.path); + if ((!human && op === 'delete' && (!entry || entry.mode !== '100644' || entry.type !== 'blob')) + || (entry && (entry.mode !== '100644' || entry.type !== 'blob')) + || tree.some(e => e.path.startsWith(change.path + '/') || change.path.startsWith(e.path + '/'))) + fail('library-write-story-path'); + if ((entry?.oid ?? null) !== change.base) fail('stale-base'); + const action = change.content === null ? 'delete' : 'write'; + const message = `Mosaic: ${action} ${change.path}` + (human ? '' : `\n\nMosaic-Agent: ${options.agent}`); + const commit = await plumbing(mirror, tip, change.path, change.content, identity, message, stage); + // Broker rechecks live grants/holder immediately before each push. + options.beforePush?.(); + const result = await push(context, { commit, credential, retried: attempt === 1, writeToken, command: options.command ?? syncCommand }); + if (!result.pushed) { tip = result.tip; continue; } + pushed = true; + await publishLibraryWrite(mirror, { mapping, commit, previous: served, readSyncState: lock.readSyncState, now: options.now }); + return { project: context.project, path: change.path, ...(revert ? { reverted: change.reverted } : {}), commit }; + } + fail('stale-base'); + } finally { + rmSync(stage, { recursive: true, force: true }); + if (!pushed) { + if (previous === null) await git(mirror, ['update-ref', '-d', tracking]); + else await git(mirror, ['update-ref', tracking, previous]); + } + } + })); +} +export const writeLibraryDoc = (context, options = {}) => write(context, options, false); +export const saveLibraryFile = (context, options = {}) => write(context, options, true); +export const revertLibraryCommit = (context, options = {}) => write(context, options, true, true); diff --git a/packages/library/tests/write.test.mjs b/packages/library/tests/write.test.mjs new file mode 100644 index 000000000..6ae8db4f8 --- /dev/null +++ b/packages/library/tests/write.test.mjs @@ -0,0 +1,179 @@ +// Row 288: real bare-repository plumbing and remote effects. All text and credentials are fixtures. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { repo, git, mappingEntry, BASE } from './helpers.mjs'; +import { SAFE } from '../../project/src/git.mjs'; +import { gitEnv } from '../../seat/src/checkout.mjs'; +import { sync, syncCommand, push, withLibraryWriteGuard } from '../src/sync.mjs'; +import { mirrorPath, readSyncState, servedCommit } from '../src/mirror.mjs'; +import { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from '../src/write.mjs'; + +const PATH = 'Book One/Notes/Agent.md'; +const IDENTITY = { name: 'Bean Fixture', email: 'bean@example.test' }; +const EDITOR = { name: 'Human Fixture', email: 'human@example.test' }; + +async function world(t, options = {}) { + const f = repo(t, { ...BASE, [PATH]: 'before\n', + 'Book One/Notes/Executable.md': { text: 'exec\n', mode: 0o755 }, + 'Book One/Notes/Link.md': { link: 'Agent.md' } }); + git(f.repo, 'update-index', '--add', '--cacheinfo', '160000', f.commit, 'Book One/Notes/Submodule.md'); + git(f.repo, 'commit', '-qm', 'gitlink fixture'); + const remote = join(f.root, 'remote.git'); + git(f.root, 'clone', '-q', '--bare', '--no-local', f.repo, remote); + const dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); + const entry = { ...mappingEntry(), user: 'human-fixture', editor: EDITOR }; + entry.universes.harbor.supporting = ['Book One/Notes/']; + const context = { business: { id: 'acme', projects: { books: entry } }, project: 'books', dataRoot }; + const file = join(f.root, 'fixture-slot'); writeFileSync(file, 'fixture-write-token-288\n', { mode: 0o600 }); + const credential = { kind: 'seat', user: 'bean-fixture', file }; + // Keep the production refspec exactly: a '+' mutant on push would overwrite the racing remote. + let beforePush = null, reject = false; + const commands = []; + const command = (kind, url, opts) => { + commands.push(kind); + if (kind === 'push' && beforePush) { const fn = beforePush; beforePush = null; fn(); } + const c = syncCommand(kind, url, opts); + const argv = [...c.argv]; + argv.splice(0, 0, '-c', 'protocol.file.allow=always'); + const at = argv.indexOf(url); + assert.ok(at >= 0); + argv[at] = kind === 'push' && reject ? join(f.root, 'missing.git') : remote; + return { argv, env: c.env }; + }; + await sync(context, { credential, command }); + const mirror = mirrorPath(context); + const refs = () => git(mirror, 'for-each-ref', '--format=%(refname) %(objectname)'); + const remoteHead = () => git(remote, 'rev-parse', 'main'); + const state = () => readSyncState(mirror, { branch: 'main' }); + const external = (path, content) => { + writeFileSync(join(f.repo, path), content); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'external'); + git(f.repo, 'push', '-q', remote, 'HEAD:main'); + }; + const args = (extra = {}) => ({ ...context, universe: 'harbor', path: PATH, + base: git(mirror, 'rev-parse', `served:${PATH}`), content: 'after\n', ...extra }); + const opts = { credential, identity: IDENTITY, agent: 'acme/bean', command }; + return { ...f, remote, context, entry, file, credential, command, mirror, refs, remoteHead, state, external, args, opts, + race: fn => { beforePush = fn; }, reject: () => { reject = true; }, commands }; +} + +test('C2-02: write/delete each publish one parent, seat ident, fixed trailer, index and lastPush', async t => { + const f = await world(t), parent = f.remoteHead(); + const saved = await writeLibraryDoc(f.args(), f.opts); + assert.deepEqual(saved, { project: 'books', path: PATH, commit: f.remoteHead() }); + assert.equal(git(f.remote, 'show', `${saved.commit}:${PATH}`), 'after'); + assert.equal(git(f.remote, 'show', '-s', '--format=%P', saved.commit), parent); + assert.equal(git(f.remote, 'show', '-s', '--format=%an <%ae>|%cn <%ce>', saved.commit), + 'Bean Fixture |Bean Fixture '); + assert.equal(git(f.remote, 'show', '-s', '--format=%B', saved.commit), `Mosaic: write ${PATH}\n\nMosaic-Agent: acme/bean`); + assert.equal(await servedCommit(f.mirror), saved.commit); + assert.equal((await f.state()).lastPush.commit, saved.commit); + assert.equal(existsSync(join(f.mirror, 'index')), false); + const gone = await writeLibraryDoc(f.args({ content: undefined }), { ...f.opts, op: 'delete' }); + assert.equal(git(f.remote, 'show', '-s', '--format=%P', gone.commit), saved.commit); + assert.equal(git(f.remote, 'ls-tree', '-r', gone.commit, '--', PATH), ''); + assert.equal(git(f.remote, 'show', '-s', '--format=%B', gone.commit), `Mosaic: delete ${PATH}\n\nMosaic-Agent: acme/bean`); +}); + +test('C2-02 M12: each path refusal leaves every ref and remote unchanged', async t => { + const f = await world(t); + const paths = ['Book One/Chapters/Chapter 01.md', 'Book One/Playground/What If.md', 'Inbox Note.md', + 'Book One/Notes/X.canvas', 'Book One/Notes/X.base', 'Book One/Notes/X.png', 'Second World/Chapters/01.md', + '/Book One/Notes/X.md', 'Book One/Notes/../X.md', 'Book One/./Notes/X.md', 'Book One//Notes/X.md', + 'Book One/Notes/X\\Y.md', 'Book One/Notes/X\nY.md', 'Book One/Notes/Link.md', + 'Book One/Notes/agent.md', `Book One/Notes/${'é'.repeat(512)}.md`]; + for (const path of paths) { + const before = f.refs(), head = f.remoteHead(); + await assert.rejects(writeLibraryDoc(f.args({ path, base: null }), f.opts), { code: 'library-write-story-path' }, path); + assert.equal(f.refs(), before); assert.equal(f.remoteHead(), head); + } +}); + +test('C2-02 Q4: delete requires an existing 100644 blob; null base never deletes a missing path', async t => { + const f = await world(t); + for (const path of ['Book One/Notes/Missing.md', 'Book One/Notes/Executable.md', 'Book One/Notes/Link.md', 'Book One/Notes/Submodule.md']) { + const before = f.refs(); + await assert.rejects(writeLibraryDoc(f.args({ path, base: null, content: undefined }), { ...f.opts, op: 'delete' }), + { code: 'library-write-story-path' }); + assert.equal(f.refs(), before); + } +}); + +test('C2-02: stale base and failed push keep remote/served/tracking refs unchanged', async t => { + const f = await world(t), refs = f.refs(), head = f.remoteHead(); + await assert.rejects(writeLibraryDoc(f.args({ base: 'a'.repeat(40) }), f.opts), { code: 'stale-base' }); + assert.equal(f.refs(), refs); assert.equal(f.remoteHead(), head); + f.reject(); + await assert.rejects(writeLibraryDoc(f.args(), f.opts), { code: 'library-push-failed' }); + assert.equal(f.refs(), refs); assert.equal(f.remoteHead(), head); +}); + +test('C2-02: a moved unrelated blob retries once on the new parent, changed target refuses', async t => { + const f = await world(t); + const args = f.args(); let raced; + f.race(() => { f.external('Inbox Note.md', 'another\n'); raced = f.remoteHead(); }); + const saved = await writeLibraryDoc(args, f.opts); + assert.equal(git(f.remote, 'show', '-s', '--format=%P', saved.commit), raced); + assert.equal(git(f.remote, 'show', `${saved.commit}:Inbox Note.md`), 'another'); + const g = await world(t), before = g.refs(); + g.race(() => g.external(PATH, 'raced target\n')); + await assert.rejects(writeLibraryDoc(g.args(), g.opts), { code: 'stale-base' }); + assert.equal(g.refs(), before); assert.equal(git(g.remote, 'show', `main:${PATH}`), 'raced target'); +}); + +test('C2-02: create with null base; human revert restores parent and does not require seat credentials', async t => { + const f = await world(t); + const made = await writeLibraryDoc(f.args({ path: 'Book One/Notes/New.md', base: null, content: 'new\n' }), f.opts); + const options = { credential: { ...f.credential, kind: 'human' }, command: f.command }; + const reverted = await revertLibraryCommit({ ...f.context, commit: made.commit }, options); + assert.deepEqual(reverted, { project: 'books', path: 'Book One/Notes/New.md', reverted: made.commit, commit: f.remoteHead() }); + assert.equal(git(f.remote, 'ls-tree', '-r', reverted.commit, '--', reverted.path), ''); + assert.equal(git(f.remote, 'show', '-s', '--format=%an <%ae>|%cn <%ce>', reverted.commit), + 'Human Fixture |Human Fixture '); + assert.equal(git(f.remote, 'show', '-s', '--format=%(trailers:key=Mosaic-Agent,valueonly)', reverted.commit), ''); +}); + +test('C2-02: revert requires reachable business trailer and refuses a later path change', async t => { + const f = await world(t), options = { credential: { ...f.credential, kind: 'human' }, command: f.command }; + await assert.rejects(revertLibraryCommit({ ...f.context, commit: f.commit }, options), { code: 'library-revert-not-agent' }); + const saved = await writeLibraryDoc(f.args(), f.opts); + await saveLibraryFile({ ...f.context, path: PATH, base: git(f.mirror, 'rev-parse', `served:${PATH}`), content: 'human later\n' }, options); + await assert.rejects(revertLibraryCommit({ ...f.context, commit: saved.commit }, options), { code: 'stale-base' }); +}); + +test('C2-02: human save reaches story, preserves UTF-8 bytes and uses editor without a trailer', async t => { + const f = await world(t), path = 'Book One/Chapters/Chapter 01.md'; + const content = '# Her text\n' + 'é'.repeat(40000) + '\n\n'; + const saved = await saveLibraryFile({ ...f.context, path, base: git(f.mirror, 'rev-parse', `served:${path}`), content }, + { credential: { ...f.credential, kind: 'human' }, command: f.command }); + assert.equal(git(f.remote, 'show', '-s', '--format=%an <%ae>', saved.commit), 'Human Fixture '); + // git helper trims; compare the stored blob byte count separately. + assert.equal(Number(git(f.remote, 'cat-file', '-s', `${saved.commit}:${path}`)), Buffer.byteLength(content)); +}); + +test('C2-02: invalid content/editor/human credential refuse before any git command', async t => { + const f = await world(t), before = f.refs(); + for (const content of ['nul\0', '\ud800', 'x'.repeat(1024 * 1024 + 1)]) + await assert.rejects(writeLibraryDoc(f.args({ content }), f.opts), { code: 'invalid-request' }); + const args = { ...f.context, path: PATH, base: null, content: 'x' }; + delete f.entry.editor; + await assert.rejects(saveLibraryFile(args, { credential: { ...f.credential, kind: 'human' }, command: f.command }), { code: 'library-mapping-invalid' }); + f.entry.editor = EDITOR; + const n = f.commands.length; + await assert.rejects(saveLibraryFile(args, { credential: { kind: 'human', user: 'human', file: '/absent' }, command: f.command }), { code: 'library-human-missing' }); + assert.equal(f.commands.length, n); assert.equal(f.refs(), before); +}); + + +test('C2-02 guard: only the exact owning token permits a push race fetch; sync refuses busy', async t => { + const f = await world(t), commit = f.remoteHead(); + f.external('Inbox Note.md', 'remote moved\n'); + await withLibraryWriteGuard(f.mirror, async token => { + await assert.rejects(sync(f.context, { credential: f.credential, command: f.command }), { code: 'library-sync-busy' }); + for (const writeToken of [null, {}, Object.freeze({})]) + await assert.rejects(push(f.context, { commit, credential: f.credential, command: f.command, writeToken }), { code: 'library-sync-busy' }); + const raced = await push(f.context, { commit, credential: f.credential, command: f.command, writeToken: token }); + assert.equal(raced.raced, true); assert.equal(raced.tip, f.remoteHead()); + }); +}); diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 2b77e88d5..1e10cf415 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -68,14 +68,14 @@ try { // the system config, Business can't resolve authority and Settings has // no notifier. A refusal printed here names no path (redactor). // --send without a bus refuses to start rather than serve a composer that can't send. - let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, libraryRevert = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null; + let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, libraryRevert = null, librarySave = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null; let processing = null, ownerCall = null; try { system = loadSystem(); const { dataRoot } = system; const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); // FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs). - ({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, libraryRevert, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn })); + ({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, libraryRevert, librarySave, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn })); // Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with // --sso and --send. if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); @@ -107,7 +107,7 @@ try { } let server; try { - server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null }); + server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null }); } catch (err) { sso?.close(); throw err; } let testerServer = null; if (testerSocket) { diff --git a/packages/webui/src/console-bus.mjs b/packages/webui/src/console-bus.mjs index df88b0ae7..5915fcd72 100644 --- a/packages/webui/src/console-bus.mjs +++ b/packages/webui/src/console-bus.mjs @@ -30,10 +30,11 @@ export function consoleBus(call, { send: sendOn = false, owner = null } = {}) { projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args), libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args), - send: null, librarySync: null, libraryRevert: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null, + send: null, librarySync: null, libraryRevert: null, librarySave: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null, }; if (sendOn) { o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.libraryRevert = args => call('library.revert', args); o.settingsWrite = args => call('limits.set', args); + o.librarySave = args => call('library.file.save', args); o.projectSave = args => call('project.file.save', args); o.nameWrite = args => call('name.set', args); o.assistantStop = args => call('assistant.stop', args); o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS); } diff --git a/packages/webui/tests/library-write-options.test.mjs b/packages/webui/tests/library-write-options.test.mjs new file mode 100644 index 000000000..5564b53b5 --- /dev/null +++ b/packages/webui/tests/library-write-options.test.mjs @@ -0,0 +1,22 @@ +// Row 288: the Console's save option carries only the caller's args over its own transport. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { consoleBus, userOptionsFor } from '../src/console-bus.mjs'; + +test('C2-02: library save option is absent without --send and forwards no editor or identity', async () => { + const calls = [], call = async (verb, args) => { calls.push({ verb, args }); return { commit: 'a'.repeat(40) }; }; + assert.equal(consoleBus(call).librarySave, null); + const args = { project: 'books', path: 'Book One/Chapters/01.md', base: 'b'.repeat(40), content: 'her text\n' }; + assert.equal((await consoleBus(call, { send: true }).librarySave(args)).commit, 'a'.repeat(40)); + assert.deepEqual(calls, [{ verb: 'library.file.save', args }]); + assert.deepEqual(Object.keys(calls[0].args).sort(), ['base', 'content', 'path', 'project']); +}); + +test('C2-02: user save option uses that user transport; owner transport is never called', async () => { + const calls = []; + const options = userOptionsFor({ owner: () => { throw Error('owner transport touched'); }, send: true, + transport: user => async (verb, args) => { calls.push({ user, verb, args }); } }); + const args = { project: 'books', path: 'Book One/Chapters/01.md', base: null, content: 'fixture' }; + await options({ user: 'reader' }).librarySave(args); + assert.deepEqual(calls, [{ user: 'reader', verb: 'library.file.save', args }]); +});