Untested: row 288 library writes, notices, revert and human saves (#1793)

This commit is contained in:
jason.woltje committed 2026-10-23 14:20:42 -05:00
1 parent 436b27f662
commit c867cada97
16 files changed
+758 -22

No files matched your search

+4
View File
@@ -5411,3 +5411,7 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-23T17:29:46Z | Gyro Vikunja571 sage-3542 Connections amendment | Branch gyro/editor-connections-design cut exactly from landed PRD1.17 30e6bb680; read REQ-LIB-5 in that PRD. Replaced stale J-b waits in parent and W4. Proposed W8 owns current authorized Markdown/DOCX links/backlinks list, labels, versioned refresh, leakage refusals and fixture tests. Build order under mos-1470/comment961 is W7 closed routing candidate, W8 landing, then W7 human first-real-import opening. No REQ-LIB-8 condition or acceptance change. Q234/mos-1363/S0 M1-M14 and post-10-27/MVP priority fixed. Static diff, twelve brief sections and append-only checks pass. No repo Node, tests, build, browser, queue mutation or live action. Fenton reviews after authorized docs branch push; Sage lands on approval. 2026-10-23T17:29:46Z | Gyro Vikunja571 sage-3542 Connections amendment | Branch gyro/editor-connections-design cut exactly from landed PRD1.17 30e6bb680; read REQ-LIB-5 in that PRD. Replaced stale J-b waits in parent and W4. Proposed W8 owns current authorized Markdown/DOCX links/backlinks list, labels, versioned refresh, leakage refusals and fixture tests. Build order under mos-1470/comment961 is W7 closed routing candidate, W8 landing, then W7 human first-real-import opening. No REQ-LIB-8 condition or acceptance change. Q234/mos-1363/S0 M1-M14 and post-10-27/MVP priority fixed. Static diff, twelve brief sections and append-only checks pass. No repo Node, tests, build, browser, queue mutation or live action. Fenton reviews after authorized docs branch push; Sage lands on approval.
2026-10-23T17:32:37Z | Gyro sage-3544 fenton-0016 N1/N2 delta | Before: W7 Gate/final real-conversion list omit the W8 build-order cross-reference and W8 human-view filtering cites agent settings. After: both locations explicitly put W8 landing before human opening as mos-1470 build order, not acceptance; comment/tracked-deletion links are not current story links and never appear in the human Connections view regardless of agent settings. New commit atop approved 5af060fb5, no amend. Static diff and append-only checks pass. Docs only, no repo Node/tests/live action. Fenton checks only this delta. 2026-10-23T17:32:37Z | Gyro sage-3544 fenton-0016 N1/N2 delta | Before: W7 Gate/final real-conversion list omit the W8 build-order cross-reference and W8 human-view filtering cites agent settings. After: both locations explicitly put W8 landing before human opening as mos-1470 build order, not acceptance; comment/tracked-deletion links are not current story links and never appear in the human Connections view regardless of agent settings. New commit atop approved 5af060fb5, no amend. Static diff and append-only checks pass. Docs only, no repo Node/tests/live action. Fenton checks only this delta.
## 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.
+117
View File
@@ -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.
+1
View File
@@ -1593,3 +1593,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-23T17:48:43Z | Scrooge (629b6cd3) | Row 252 Console inbox Approve and Deny (#1753) | Rebased onto 306 landing as 580037105 (range-diff all =), slot run bus 907/0 webui 410/0 cli 943/0, in-review revs 3608/3609, Webby r1 APPROVE, J1-J19 specs to Sage; Sage landed 45ea3fa0d (done rev 3616). Part 1 mutant results were never in BUILD.md; they went to Sage by T3 (scrooge-0252-mutant-specs). 2026-10-23T17:48:43Z | Scrooge (629b6cd3) | Row 252 Console inbox Approve and Deny (#1753) | Rebased onto 306 landing as 580037105 (range-diff all =), slot run bus 907/0 webui 410/0 cli 943/0, in-review revs 3608/3609, Webby r1 APPROVE, J1-J19 specs to Sage; Sage landed 45ea3fa0d (done rev 3616). Part 1 mutant results were never in BUILD.md; they went to Sage by T3 (scrooge-0252-mutant-specs).
2026-10-23 | Sage (1ef1e4f8) | Rows 308 and 252 landings, 571 Connections design amendment, Vikunja 597 | 308 (PRD 1.17, #1815) landed 30e6bb680 and 252 (#1753) landed 45ea3fa0d (replay of 580037105 onto 30e6bb680, range-diff all =), each after a full gate on the landed tree (252 also J1-J19, J11c and J18 equivalent); Gyro's 571 amendment (Fenton APPROVE) landed e8bb13df8; Vikunja 570, 435 and 597 done, canonical webui esbuild 0.28.2; queue revs 3589-3616 committed 59f013247 2026-10-23 | Sage (1ef1e4f8) | Rows 308 and 252 landings, 571 Connections design amendment, Vikunja 597 | 308 (PRD 1.17, #1815) landed 30e6bb680 and 252 (#1753) landed 45ea3fa0d (replay of 580037105 onto 30e6bb680, range-diff all =), each after a full gate on the landed tree (252 also J1-J19, J11c and J18 equivalent); Gyro's 571 amendment (Fenton APPROVE) landed e8bb13df8; Vikunja 570, 435 and 597 done, canonical webui esbuild 0.28.2; queue revs 3589-3616 committed 59f013247
2026-10-23 | Sage (1ef1e4f8) | Vikunja 571 W build rows; rows 13, 42, 257 and 289 check | Filed the design's ten W rows as queue rows 309-318 (revs 3617-3626, owners set to unassigned 3627-3636, queue commit 293b42fee pushed); Vikunja 571 done (comment 994). Row 13's candidate fd72d2689 is on refactor, gate is Monday's row 7 ledger run; 42 paused under mos-1184; 257 post-v1; 289 waits on 214 and 288. Slot released 17:56Z. 2026-10-23 | Sage (1ef1e4f8) | Vikunja 571 W build rows; rows 13, 42, 257 and 289 check | Filed the design's ten W rows as queue rows 309-318 (revs 3617-3626, owners set to unassigned 3627-3636, queue commit 293b42fee pushed); Vikunja 571 done (comment 994). Row 13's candidate fd72d2689 is on refactor, gate is Monday's row 7 ledger run; 42 paused under mos-1184; 257 post-v1; 289 waits on 214 and 288. Slot released 17:56Z.
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.
+80 -10
View File
@@ -100,7 +100,7 @@ export const TASK_PAGE_MAX = 200;
// request allowed past the ordinary 64 KiB envelope (server.mjs and client.mjs), up to its content cap. // request allowed past the ordinary 64 KiB envelope (server.mjs and client.mjs), up to its content cap.
export const PROJECT_FILE_VERBS = Object.freeze(['project.files', 'project.file', 'project.file.save']); export const PROJECT_FILE_VERBS = Object.freeze(['project.files', 'project.file', 'project.file.save']);
// C2-03a: library.sync is the human's fetch, and with `accept` the one way past library-diverged. // C2-03a: library.sync is the human's fetch, and with `accept` the one way past library-diverged.
export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync']); export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync', 'library.file.save', 'library.revert', 'library.doc.write', 'library.doc.delete']);
export const PROJECT_SAVE_VERB = 'project.file.save'; export const PROJECT_SAVE_VERB = 'project.file.save';
export const PROJECT_SAVE_REQUEST_MAX = 1024 * 1024 + 64 * 1024; export const PROJECT_SAVE_REQUEST_MAX = 1024 * 1024 + 64 * 1024;
export const WEB_VERBS =Object.freeze(['web.search', 'web.fetch']); export const WEB_VERBS =Object.freeze(['web.search', 'web.fetch']);
@@ -5161,25 +5161,95 @@ export class Broker {
throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request); throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request);
} }
} }
// C2-04 M4: only a verified human reads library text or status. Host Git // C2-02: all authority and credential choice stays on the host. Neither an agent's tool
// functions stay injected, preserving this module's sandbox import closure. // 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) { async #requestLibrary(s, request) {
const { verb, args } = request; const { verb, args } = request;
try { try {
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); this.#human(s);
const fields = verb === 'library.file' ? ['project', 'path'] : verb === 'library.sync' ? ['project', 'accept'] : ['project']; 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); 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'); if (typeof project !== 'string' || b.projects?.[project]?.kind !== 'library') fail('project-not-configured');
const context = { business: this.#business(s.business), project: args.project }; 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; 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); else if (verb === 'library.status') result = await this.#library.status(context);
else { 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 }); const { state, code } = await this.#library.sync(context, { accept: args.accept ?? null });
if (code !== null) fail(code); if (code !== null) fail(code);
result = { project: args.project, ...state }; result = { project, ...state };
} }
this.#secretCheck(result); this.#secretCheck(result);
return result; return result;
+1 -1
View File
@@ -17,7 +17,7 @@ export class Client {
verb, verb,
args, args,
}) + '\n'; }) + '\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 Promise.reject(new BusError('request-too-large'));
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const socket = connect(this.path); const socket = connect(this.path);
+14
View File
@@ -237,6 +237,12 @@ export async function startBroker({
credential: fetchCredential(business), credential: fetchCredential(business),
...(librarySync ? { command: librarySync.command } : {}), ...(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 // 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 // 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 // and the tracker bots are fixed at start (the Credentials store, the tasks adapter), so a file
@@ -313,6 +319,14 @@ export async function startBroker({
readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }), readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }),
status: context => libraryModule.libraryStatus({ ...context, dataRoot }), status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })), 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, LibraryError: libraryModule.LibraryError,
}, },
project: { project: {
+4 -2
View File
@@ -6,7 +6,9 @@ import { createPeerReader } from './peercred.mjs';
const LIMIT = 65536; const LIMIT = 65536;
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near // 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. // 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) };
// One request per connection. There is deliberately no reconnect/retry or SQL verb. // One request per connection. There is deliberately no reconnect/retry or SQL verb.
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it. // `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only // `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
@@ -60,7 +62,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
try { try {
if (end !== input.length - 1) throw new BusError('invalid-envelope'); if (end !== input.length - 1) throw new BusError('invalid-envelope');
const r = JSON.parse(input.subarray(0, end).toString('utf8')); 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 ( if (
!r || !r ||
Array.isArray(r) || Array.isArray(r) ||
+112
View File
@@ -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: '[email protected]', 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' });
});
+2
View File
@@ -9,3 +9,5 @@ export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
export { survey, formatSurvey, surveyMain } from './survey.mjs'; export { survey, formatSurvey, surveyMain } from './survey.mjs';
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs'; export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
export { syncCommand, sync, push, humanCredential, ASKPASS, SLOT, HUMAN_SLOT, CREDENTIAL_KINDS, SYNC_INTERVAL_MS } from './sync.mjs'; export { syncCommand, sync, push, humanCredential, ASKPASS, SLOT, HUMAN_SLOT, CREDENTIAL_KINDS, SYNC_INTERVAL_MS } from './sync.mjs';
export { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from './write.mjs';
+3 -1
View File
@@ -221,7 +221,9 @@ async function readStateHeld(mirror, { branch } = {}) {
finally { if (fd !== undefined) closeSync(fd); } finally { if (fd !== undefined) closeSync(fd); }
// C2-03b adds `credential`, { kind, user } or null. It is optional, so a state from before it reads. // 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']; 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) || !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)) && typeof v.user === 'string' && /^[A-Za-z0-9_.-]{1,100}$/.test(v.user))
|| value.version !== 1 || value.branch !== branch || value.served !== served || value.version !== 1 || value.branch !== branch || value.served !== served
+35 -4
View File
@@ -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 // 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. // fetch into the same mirror is refused here rather than racing the first.
const running = new Set(); 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 // 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. // 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 // 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 // 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. // 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 mirror = await ensureMirror(context);
const mapping = checkMapping(context.business.projects[context.project]); const mapping = checkMapping(context.business.projects[context.project]);
const { branch } = mapping; 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'); 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 // 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. // 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'); const owned = writeToken !== null && writers.get(mirror) === writeToken;
running.add(mirror); if (running.has(mirror) && !owned) fail('library-sync-busy', 'a sync of this library is running');
if (!owned) running.add(mirror);
try { try {
const fetch = command('fetch', mapping.remote, opts); 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'); 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'); } 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'); if (!SHA.test(tip)) fail('library-push-failed', 'fetch after a moved tip found no branch');
return { pushed: false, raced: true, tip }; 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;
} }
+179
View File
@@ -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);
+179
View File
@@ -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: '[email protected]' };
const EDITOR = { name: 'Human Fixture', email: '[email protected]' };
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 <[email protected]>|Bean Fixture <[email protected]>');
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 <[email protected]>|Human Fixture <[email protected]>');
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 <[email protected]>');
// 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());
});
});
+3 -3
View File
@@ -68,14 +68,14 @@ try {
// the system config, Business can't resolve authority and Settings has // the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor). // 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. // --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, 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, 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; let processing = null, ownerCall = null;
try { try {
system = loadSystem(); system = loadSystem();
const { dataRoot } = system; const { dataRoot } = system;
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); 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). // 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, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn })); ({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, 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 // Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
// --sso and --send. // --sso and --send.
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
@@ -107,7 +107,7 @@ try {
} }
let server; let server;
try { try {
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, 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, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null });
} catch (err) { sso?.close(); throw err; } } catch (err) { sso?.close(); throw err; }
let testerServer = null; let testerServer = null;
if (testerSocket) { if (testerSocket) {
+2 -1
View File
@@ -30,10 +30,11 @@ export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
projectFiles: args => call('project.files', args), projectFiles: args => call('project.files', args),
projectFile: args => call('project.file', args), projectFile: args => call('project.file', args),
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args), libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
send: null, librarySync: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null, send: null, librarySync: null, librarySave: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
}; };
if (sendOn) { if (sendOn) {
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.settingsWrite = args => call('limits.set', args); o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', 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.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); o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS);
} }
@@ -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 }]);
});