Untested: row 288 library writes, notices, revert and human saves (#1793)
This commit is contained in:
1 parent
436b27f662
commit
c867cada97
16 files changed
+760
-24
No files matched your search
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+82
-12
@@ -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 {
|
||||||
this.#human(s);
|
const agent = verb === 'library.doc.write' || verb === 'library.doc.delete';
|
||||||
const fields = verb === 'library.file' ? ['project', 'path'] : verb === 'library.sync' ? ['project', 'accept'] : ['project'];
|
const b = this.#business(s.business);
|
||||||
keys(args, fields, verb === 'library.sync' ? ['project'] : fields);
|
let project = args.project, writer;
|
||||||
if (!this.#library?.readFile || !this.#library?.status) fail('project-not-configured');
|
if (agent) {
|
||||||
if (verb === 'library.sync' && !this.#library.sync) fail('project-not-configured');
|
// The caller gate precedes argument checks, including human and tester refusals.
|
||||||
const context = { business: this.#business(s.business), project: args.project };
|
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;
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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) ||
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }]);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user