diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 2d261a286..149428b86 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -5554,3 +5554,5 @@ claim. Receipts and limitations in agents/lena/work/queue-296/BUILD.md and run-r 2026-10-24T15:55:22Z | Sage | Row 225 (#1726, C2-10b, Fethry on Codex, Dewey) LANDED 9b6345466. Before: refactor at c63801959. After: Fethry's r2 candidate c1f9dfd3c (11 commits on 963303403) cherry-picked onto c63801959. 8 commits are equal by range-diff; the other 3 differ only in the append-only log hunks, kept both sides with 0 lines removed against either parent. All 13 manifest paths match manifest.r2.sha256 (517af41b). Dewey APPROVE c31599 rev 3960; request c31600 revs 3961-3962, posted by Sage because Fethry has no token. Sage gate GREEN 15:54:58Z with its own npm ci and MOSAIC_REQUIRE_EDITOR_BUILD=1: every package suite except queue 0 fail (webui 442, library 149, bus 981, cli 988 plus the allow-listed FED R7 skip, whose runner passed 1/1), all 12 scripts rc 0. The excluded M3 timeout attempt was not counted; the unchanged retry killed cleanly. Dewey N1-N3 non-blocking: the Discord relay must pass check once it calls renderReply; the M3 hang was in the new status test's after-hook teardown; the pre-217 residual stands under sage-3669. #1726 closed c31604. Not re-pinned. 2026-10-24T16:12:35Z | Sage | Row 287 (#1792, C2-21, Steelbeak on Codex, Boyd and Dewey) LANDED 0e935776e. Before: refactor at 9b6345466. After: Steelbeak's c2338ed58 and doc follow-up a5370b198 cherry-picked onto 9b6345466. One commit equal by range-diff, the other differs only in log hunks, kept both sides. 56 manifest paths match 3683b40a. Boyd APPROVE c31602 rev 3967, Dewey APPROVE c31603 rev 3968; request c31601 revs 3965-3966 posted by Sage. Sage gate GREEN 16:11:38Z: bus 998 (Gitea Docker F-A/F-B and X-G3, minted 4, hits 0), business 283, webui 442, cli 989 plus the allowed FED R7 skip, every other suite 0 fail, all 12 scripts rc 0; fixture credential values in 0 of 29 run files with a positive control. Rulings applied: sage-4125 (library grants mint declared scopes, chief read), mos-1590 (new-test fixture fixes are the lead's call when no production code or old test changes and every fail-closed refusal keeps its own test), sage-4139 (fixture fixes) and sage-4141 (test-land-work.sh is mode 100644, run via bash). Follow-up: F-C negative, a protected Story/** rule refuses the owner too; binds the first row that needs owner pushes to a protected branch. Dewey N1-N3 non-blocking: the block's last line "can write every repository this user can write" contradicts the chief's read grant; a collaborator entry outlives a failed step 5, which still fails closed; #publishFacts would accept a library project, unreachable today. Closes Dewey's 286 r2 N2 (T7/T9 real mint). #1792 closed c31606. Not re-pinned. + +- 2026-10-24 — steelbeak — #1728 / row227 before: transplanted Gizmoduck's unrun C2-14 driver and packet onto refactor487500a16 (471717c59/8039fc1a9). Recovered one-driver/sync-retry/read-only-store choices; replacing no-launcher stand-in with landed row217 harness. Landed-row skips need activation; row288 write/revert/save dependency remains open. Static only under sage-4166; no Node/npm/tests before CLEAR. Notes: agents/steelbeak/work/queue-227/BUILD.md. diff --git a/agents/steelbeak/work/queue-227/BUILD.md b/agents/steelbeak/work/queue-227/BUILD.md new file mode 100644 index 000000000..736316c4c --- /dev/null +++ b/agents/steelbeak/work/queue-227/BUILD.md @@ -0,0 +1,114 @@ +# Row 227 C2-14 (#1728): Steelbeak rebuild + +Assigned by Sage (sage-4166, 2026-10-24), reviewer Dewey. Worktree +/home/jwoltje/.t3/worktrees/mosaic-stack/steelbeak-row227, branch steelbeak/row227, +base refactor 487500a16f4ab5902c318509fdec6acc56a543ca. + +## Recovered packet and choices + +Gizmoduck's packet is preserved, unchanged, under agents/gizmoduck/work/queue-227. +Cherry-picks: 2d1efa0de -> 471717c59 and 5583fa775 -> 8039fc1a9. Both clean. +Read Choices first: one driver process owns fake TLS Gitea lifetime, the runtime's startup +sync can require library-sync-busy retries, and refusal evidence uses a read-only store handle. +Human bind remains an explicitly labelled in-process stand-in. The no-launcher stand-in +comes out under sage-3608 now that row217 is landed: reuse its scripted citation adapter +through the real launcher and fakeHarnesses module loader. + +## Authority and state + +Static edits, local git and own-branch pushes are authorized. NO Node invocation (including +node --check), npm, driver run, suites or mutants before Sage's CLEAR. Row257 owns the slot; +266/224/214 precede this row. No code in this packet has been executed by this seat. + +## Current dependency delta + +Canonical queue inspection: 216/217/220/225/226/283/285/286/287 are done. Row288 remains +briefed (Bubba); row289 remains briefed. The runtime has no library.write, library.revert or +library.file.save implementation yet, so S6-S9 and S10's real write notice remain dependent +on288. Reported to Sage in steelbeak-0020 with a recommendation to keep pending steps +explicit and never count them as complete acceptance. Independent landed-step rebuild proceeds. + +## Planned static work + +1. Replace manual bindLaunch S3b with the existing scripted citation adapter behind createLauncher. +2. Activate supporting mapping/editor, real lookup/citation guard/storage and playground-canon refusal. +3. Adapt runtime/config/credential fixture to current roles while keeping all state scratch-only. +4. Add the Console render path for checked replies; write/revert/save/notice follow landed APIs only. +5. Shorten fixed fixture credentials to under24 characters; ensure output diagnostics never contain one. +6. Search packages/*/tests and scripts for hash pins on each changed path; record searches and controls. +7. Prepare mutant and full-script gate plan, then push a plainly UNTESTED packet and manifest for CLEAR. + +## Runs + +None. Static reads and git operations only. + +## Sage ruling sage-4167 + +No slot request until row288 lands. Build landed steps now; draft S6-S9 and S10 notice against +row288's brief, but no stub, skip or acceptance count. After224 ->284 ->288 lands, rebase and +complete S6-S10, then request one slot for one full acceptance run with nothing skipped. +Row already in-progress at3984; do not retry the refused queue-start or touch its lock. + +| Pending step | Row288 verb/evidence | State | +| --- | --- | --- | +| S6 | library.doc.write; one seat-authored parented commit, trailer and broker notice | drafted, unrun | +| S7 | library.doc.write story refusal library-write-story-path; refs unchanged | drafted, unrun | +| S8 | library.revert {project,commit}, A2 sage-3596; editor-authored revert commit | drafted, unrun | +| S9 | library.file.save {path,base,content}; editor-authored save commit | drafted, unrun | +| S10 notice | actual broker library-write message from S6 through Console HTTP and DOM | drafted, unrun | + +The source contains unconditional implementations of all17 steps and no skip writer. None is +counted toward acceptance before the gated full run. S6-S10 signatures and runtime wiring must be +reconciled with actual288 landing before requesting that run. + +## Static rebuild so far + +New fixture modules scripts/case2/launch.mjs and console.mjs keep the one-driver lifecycle. +The launcher uses existing CLI fixture config, real bootConfig/startBroker/createLauncher and +C2-06 citation-adapter via an adapter shell wrapper. Only a fixture grant is seeded, as the brief +requests; no executor mint is substituted into row227. Actual launcher bind results are captured +for later seat requests; no manual bindLaunch stand-in remains. Secret store files are outside +data root, with distinct short fetch/seat values. The real message guard supplies stored checks; +refusal/event reads remain read-only. The Console path uses consoleBus and startServer with +actual chat rows, plus repository browser helper; only unrelated Board service is a local503. + +Checks performed: bash -n scripts/test-case2.sh passed; git diff --check passed. Neither parses +or executes JavaScript. NO Node/npm/driver/suites/mutants have run. + +## Static preparation receipt + +- Preserved Gizmoduck packet byte-for-byte against origin/gizmoduck/row227. +- bash -n scripts/test-case2.sh: rc0. JavaScript syntax has NOT been checked. +- git diff --check: rc0 on current edits. +- static/hash-pin-search.txt records literal-path searches across packages/*/tests and scripts for + every changed source/note path. No path-specific hash pins; only the driver self-reference and + BUILD-LOG.md's gate-lane copy-exclusion entry. case1-accept.sh references are the positive control. +- static/source-review.txt records the17 step ids in order and absence of a skip writer. This is + source inspection, NOT an execution or an acceptance count. +- No Node, npm, driver run, suite, browser, launcher or mutant has run. All helper code is UNTESTED. + +Pre-CLEAR manifest is preparatory, not a frozen review candidate or a slot request. Static commits +and pushes are explicitly authorized by sage-4166. No test or acceptance success is claimed. + +## Completion after row288 lands + +1. Rebase onto its refactor landing, retaining the two original cherry-pick receipts and packet. +2. Reconcile224/284 lookup/tool snapshots and288 write/revert/save interfaces against the actual code. + Current S8 follows A2's {project,commit}; current S9 follows the brief's {path,base,content}. +3. Finish/verify S6-S10's real write, story refusal, revert, save and actual Console notice path. +4. Send one slot request to Sage for the whole script, as sage-4167 requires; wait for CLEAR. +5. Under CLEAR install pinned local dependencies in both worktree root and packages/webui with + npm ci --ignore-scripts --no-audit --no-fund; use a new short mktemp TMPDIR. No canonical npm/linking. +6. Execute the full script once with all17 steps and zero skips. Any first-run new-test RED is + placed/reported; an old-test/product/loosened assertion or subsequent RED goes to Sage first. +7. Proposed semantic negatives: disable playground-canon check ->S4; serve force-pushed tip ->S5; + remove supporting-path enforcement ->S7; omit agent trailer/notice ->S6/S10; wrong editor ->S8/S9; + remove stored guard metadata ->S3c/S10. A timeout is never a mutant kill. Restore byte-for-byte. +8. Run applicable library/bus/harness/CLI/webui suites and every scripts/test-*.sh using bash with + timeout -k10; MOSAIC_REQUIRE_EDITOR_BUILD=1 for webui. FED skip needs its dedicated same-gate runner. + Redo hash-pin search after reconciliation, inspect token/output and owned-process cleanup receipts. +9. Freeze final tested manifest and hand off directly to Dewey/Sage. Dewey runs the script; Sage's + landing gate must also pass. No live host or model, no role expansion, no merge/deploy authorized. + +The sole present boundary is row288's unlanded dependency plus explicit no-execution/no-slot ruling; +no Jason decision or secret is requested. Leave row227 in-progress, as instructed. diff --git a/agents/steelbeak/work/queue-227/PRE-CLEAR-MANIFEST.txt b/agents/steelbeak/work/queue-227/PRE-CLEAR-MANIFEST.txt new file mode 100644 index 000000000..0642f3705 --- /dev/null +++ b/agents/steelbeak/work/queue-227/PRE-CLEAR-MANIFEST.txt @@ -0,0 +1,10 @@ +3e78b4cbabc028295045095c2538d0ea805680cb686a2b48f468c7dc3a606ca1 BUILD-LOG.md +a29f02b908b09cdcf61a7ac4090665206c0527f8c4b90c0bf0b1100f7d4bcb4c agents/gizmoduck/work/queue-227/BUILD.md +68b1e4cbeac27181368d9ff2d2f1a1ff3884cbcdbe0b8e0e4b48d25385fa6d58 agents/steelbeak/work/queue-227/BUILD.md +0b5b7ce94a4e36f748cc85031929aa664012a97683483297724670f94ca0e29a agents/steelbeak/work/queue-227/static/hash-pin-search.txt +a888b2fa6a9b4eeaecfea58639e46e3597bd7edf4f65ea6d5bb4e3c4032debd9 agents/steelbeak/work/queue-227/static/source-review.txt +f8ed4a3d100309250aaf4e1a50b7787ce176ef7f2941a23c114dc8f7ed5e8e2e docs/SESSIONS.md +5fb4eba80ace7a64959f2fefbea8c4521d0054bbe348df70c0ecb694c2e5ed65 scripts/case2/console.mjs +9ff32edf8af389868f92d502a7a7d2ff65e77174dc7523dd22e27919ce94ce90 scripts/case2/e2e.mjs +2e9d27ba06f968fd4320cad7243131bca57f4945075878d5d868850c514dc6de scripts/case2/launch.mjs +f903c1de232314c9e0795ce1c8c3a8ff3bb38c91bc46e6405e55e6ef3f1969a4 scripts/test-case2.sh diff --git a/agents/steelbeak/work/queue-227/static/hash-pin-search.txt b/agents/steelbeak/work/queue-227/static/hash-pin-search.txt new file mode 100644 index 000000000..63b7ed45a --- /dev/null +++ b/agents/steelbeak/work/queue-227/static/hash-pin-search.txt @@ -0,0 +1,18 @@ +Static hash-pin search, no Node execution. +Searched every changed path in packages/*/tests and scripts. +scripts/test-case2.sh: rg rc 1 +scripts/case2/e2e.mjs: rg rc 0 +scripts/case2/e2e.mjs:245:if (!root) { process.stderr.write('usage: node scripts/case2/e2e.mjs \n'); process.exit(2); } +scripts/test-case2.sh:33:DRIVER=scripts/case2/e2e.mjs +scripts/case2/launch.mjs: rg rc 1 +scripts/case2/console.mjs: rg rc 1 +agents/gizmoduck/work/queue-227/BUILD.md: rg rc 1 +agents/steelbeak/work/queue-227/BUILD.md: rg rc 1 +BUILD-LOG.md: rg rc 0 +packages/cli/tests/gate-lane-auth.test.mjs:49:const SKIP_COPY = new Set(['.git', 'node_modules', 'v1', 'agents', 'docs', 'BUILD-LOG.md', join('scripts', 'foundation')]); +docs/SESSIONS.md: rg rc 1 +Positive control: existing case1-accept.sh references (first three) +1:// Row 195 (#1693): scripts/case1-accept.sh, root's acceptance run on the Case 1 host. Design r7 section 5 +19:const SCRIPT = join(REPO, "scripts", "case1-accept.sh"); +755: "retired TCP": [(h) => put(h.args, [...ARGS.slice(0,-1),'--tester-port','7333'].join('\n')+'\n',0o644), /case1-accept.sh retires tester TCP; use --tester-socket$/], +Conclusion: only new self-reference and gate-lane copy-exclusion references; no path-specific hash pins on changed files. diff --git a/agents/steelbeak/work/queue-227/static/source-review.txt b/agents/steelbeak/work/queue-227/static/source-review.txt new file mode 100644 index 000000000..56123fedf --- /dev/null +++ b/agents/steelbeak/work/queue-227/static/source-review.txt @@ -0,0 +1,6 @@ +Static source inventory only; no JavaScript parse or execution. +Declared step ids: S1a S1b S2a S2b S2c S2d S3a S3b S3c S4 S5a S5b S6 S7 S8 S9 S10 +Step ids match required order: yes +Skip writer/call in driver: absent +Pending S6-S9/S10 notice are unconditional future-interface drafts, not acceptance passes. +Preserved human bind stand-in; creative session uses actual launcher and row217 adapter. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 19acb8b6a..66c6ec2cd 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1678,3 +1678,5 @@ are never rewritten or removed; corrections are new entries. - 2026-10-24 — steelbeak — row 287 C2-21 (#1792), builder complete under sage-4130: collaborator library grants and chief real read mint built; bus 998/business 283/CLI 989+FED1/Docker2/install27/all12 shell gates green, M1-M11 killed/restored, token/cleanup audits clean; preparing steelbeak/row287 push and manifest handoff to Boyd then Dewey, no live change or landing. - 2026-10-24 — steelbeak — row 287 first-review follow-up: Boyd approved c2338ed58/3683b40... with two doc-only corrections; append-only BUILD correction distinguishes clean product/test diff from raw-log whitespace and records old-test numstats. Queue round1 open; missing-seat-credential request failure reported to Sage. Frozen product and manifest preserved; no tests or slot use. 2026-10-24T16:12:35Z | Sage (1ef1e4f8) | Rows 225 and 287 landed (mos-1576, mos-1590) | 225 9b6345466 (Dewey c31599, gate GREEN 15:54:58Z, #1726 closed c31604); 287 0e935776e (Boyd c31602, Dewey c31603, gate GREEN 16:11:38Z, #1792 closed c31606); F-C owner-push follow-up and Dewey N1-N3 recorded in BUILD-LOG; no re-pin. +- 2026-10-24 — steelbeak — row227 C2-14 (#1728), sage-4166: new worktree steelbeak-row227 on refactor487500a16, Gizmoduck commits transplanted cleanly to471717c59/8039fc1a9, packet preserved; static rebuild underway, no Node/npm/tests before CLEAR, remaining row288 dependency reported. +- 2026-10-24 — steelbeak — row227 static packet: real-launcher C2-06 adapter and checked-reply/Console path drafted, all17 source steps unconditional; S6-S9/S10 notice pending on288 under sage-4167. Shell syntax/whitespace and hash-pin searches only; no Node/npm/tests/browser/launcher/mutants. Preparing clearly UNTESTED seat-branch push; no slot request until288 lands. diff --git a/scripts/case2/console.mjs b/scripts/case2/console.mjs new file mode 100644 index 000000000..b832f7aba --- /dev/null +++ b/scripts/case2/console.mjs @@ -0,0 +1,43 @@ +// C2-14 S10: actual Console HTTP chat endpoints and browser DOM over the same acceptance bus. +// The unrelated Board is a local 503 fixture. No chat response, citation check or notice is stubbed. +import { createServer } from 'node:http'; +import { startServer } from '../../packages/webui/src/serve.mjs'; +import { consoleBus } from '../../packages/webui/src/console-bus.mjs'; +import { browser } from '../../packages/webui/tests/browser.mjs'; + +const shutdown = server => new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + server.closeAllConnections?.(); +}); +const expect = (value, label) => { if (!value) throw new Error(label); }; +export async function consoleAcceptance({ humanCall, thread, reply, writeNotice, writeCommit, cleanups }) { + const board = createServer((req, res) => { res.writeHead(503); res.end('Acceptance Board is not configured.'); }); + await new Promise(resolve => board.listen(0, '127.0.0.1', resolve)); + cleanups.push(() => shutdown(board)); + const options = consoleBus(humanCall); + const server = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}`, ...options, log: () => {} }); + cleanups.push(() => shutdown(server)); + const origin = `http://127.0.0.1:${server.address().port}`; + const response = await fetch(`${origin}/api/chat/threads/${thread}/messages`); + expect(response.ok, 'Console message endpoint refused'); + const body = await response.json(); + expect(Array.isArray(body.rows), 'Console message shape'); + const checked = body.rows.find(m => m.id === reply.id), notice = body.rows.find(m => m.id === writeNotice.id); + expect(checked?.body === reply.body && checked.check?.citations?.[0]?.scope === 'canon', 'Console dropped stored reply/check'); + expect(notice?.body === writeNotice.body, 'Console dropped broker notice'); + const view = await browser({ machine: false }); + cleanups.push(() => view.close()); + await view.navigate(`${origin}/#/chat/threads/${thread}`); + const rendered = await view.evaluate(`(async () => { + for (let i = 0; i < 150; i++) { + const bodies = [...document.querySelectorAll('.chat-log .chat-body')].map(e => e.textContent); + const notices = [...document.querySelectorAll('.chat-notice > span:first-child')].map(e => e.textContent); + const canon = [...document.querySelectorAll('.chat-cites')].some(e => e.textContent.includes('canon')); + if (bodies.some(t => t === 'Grey eyes.') && canon && notices.some(t => t === ${JSON.stringify(`bean wrote ${JSON.parse(writeNotice.body).path} @${writeCommit.slice(0, 7)}`)})) + return { reply: true, canon, notice: true }; + await new Promise(resolve => setTimeout(resolve, 40)); + } + return { reply: false, canon: false, notice: false }; + })()`); + expect(rendered.reply && rendered.canon && rendered.notice, 'Console did not render actual checked reply and write notice'); +} diff --git a/scripts/case2/e2e.mjs b/scripts/case2/e2e.mjs index 07fe76c7a..d88e1c6f2 100644 --- a/scripts/case2/e2e.mjs +++ b/scripts/case2/e2e.mjs @@ -1,219 +1,257 @@ -// C2-14 (#1728, row 227): the Case 2 end-to-end steps on a scratch root, driven by -// scripts/test-case2.sh. Usage: node scripts/case2/e2e.mjs -// -// One process holds her remote (row 199's fake Gitea on 127.0.0.1 over TLS with a scratch CA), a bus -// on a scratch data root and the steps, because the remote's port and CA live only as long as it does. -// It prints one line per step: -// ok -// not ok : -// skip waits on row : -// and exits 0 when it got to the end, 1 when a step threw outside its own check. -// -// Two stand-ins, both honest about what they are: -// - The human. human-cli.mjs proves its caller is a human shell and refuses any process with an agent -// among its ancestors (packages/bus/src/human.mjs), so a seat or a reviewer running this script can't -// use it. The driver binds the human in-process with bindHuman, as the bus tests do. -// - The fake harness. No launcher and no model: the driver binds the creative instance's run on her -// thread, claims it, receives her message and answers it. -// -// Nothing here reads a real key or reaches a real host. The token is made up and lives only in the slot. +// C2-14 (#1728): one scratch-root acceptance driver. No live host or real model. +// The human uses bindHuman in-process because a development agent cannot prove human CLI ancestry. +// The creative session is real createLauncher -> runner -> C2-06's scripted citation adapter. +// S6-S10 are drafted against row288's brief; do not run/request a slot until288 lands (sage-4167). import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { DatabaseSync } from 'node:sqlite'; import { scratchCa, fakeGitea } from '../../packages/seat/tests/fake-gitea.mjs'; import { BASE, MARKER, git, mappingEntry } from '../../packages/library/tests/helpers.mjs'; -import { startBroker } from '../../packages/bus/src/runtime.mjs'; import * as library from '../../packages/library/src/index.mjs'; +import { launchFixture } from './launch.mjs'; +import { consoleAcceptance } from './console.mjs'; -const TOKEN = 'fixture-token-c2-14-made-up-0227'; -const USER = 'books-bot'; -const BUSINESS = 'acme'; -const companion = JSON.parse(readFileSync(fileURLToPath(new URL('../../roles/companion.json', import.meta.url)), 'utf8')); - -const lines = []; -const out = (line) => { lines.push(line); process.stdout.write(line + '\n'); }; -// step(id, text, fn): fn returns nothing on a pass and throws on a fail. +const FETCH_TOKEN = 'fx227-fetch-q7'; +const SEAT_TOKEN = 'fx227-seat-q7'; +const USER = 'books-bot', BUSINESS = 'acme'; +const CHARACTER = 'Book One/Characters/Mara Vell.md'; +const SUPPORT = 'Book One/Notes/Eyes.md'; +const WRITE = 'Book One/Notes/Agent.md'; +const EDITOR = { name: 'Fixture author', email: 'author@example.test' }; +const secrets = [FETCH_TOKEN, SEAT_TOKEN]; +const redact = value => secrets.reduce((s, token) => s.replaceAll(token, '[fixture credential]'), String(value)); +let failures = 0; +const out = line => process.stdout.write(redact(line) + '\n'); +const expect = (cond, message) => { if (!cond) throw new Error(message); }; async function step(id, text, fn) { try { await fn(); out(`ok ${id} ${text}`); } - catch (e) { out(`not ok ${id} ${text}: ${String(e?.code ?? '')} ${String(e?.message ?? e).split('\n')[0].slice(0, 300)}`.trimEnd()); } + catch (e) { failures++; out(`not ok ${id} ${text}: ${e?.code ?? ''} ${String(e?.message ?? e).split('\n')[0].slice(0, 300)}`.trimEnd()); } } -const skip = (id, rows, text) => out(`skip ${id} waits on row ${rows.join(',')}: ${text}`); -const expect = (cond, message) => { if (!cond) throw new Error(message); }; -const refuses = async (promise, code) => { - try { await promise; } catch (e) { if (e?.code === code) return; throw new Error(`refused ${e?.code ?? e?.message}, expected ${code}`); } +async function refuses(promise, code) { + try { await promise; } + catch (e) { if (e?.code === code) return; throw new Error(`refused ${e?.code ?? 'unknown'}, expected ${code}`); } throw new Error(`answered, expected ${code}`); -}; - -// Every file under `dir` that holds the token, as bytes. +} +async function until(fn, label, ms = 20000) { + const end = Date.now() + ms; + while (Date.now() < end) { const result = await fn(); if (result) return result; await new Promise(r => setTimeout(r, 30)); } + throw new Error(`timed out: ${label}`); +} function holdsToken(dir) { for (const name of readdirSync(dir, { recursive: true })) { const file = join(dir, name); let st; try { st = statSync(file); } catch { continue; } - if (st.isFile() && readFileSync(file).includes(TOKEN)) return file; + if (st.isFile() && secrets.some(value => readFileSync(file).includes(value))) return file; } return null; } +function readEvents(dataRoot, where = "kind='action.refused'") { + const db = new DatabaseSync(join(dataRoot, 'bus', 'bus.sqlite'), { readOnly: true }); + try { return db.prepare(`SELECT body FROM events WHERE ${where} ORDER BY seq`).all().map(row => JSON.parse(row.body)); } + finally { db.close(); } +} + +const cleanups = []; +let closing = null; +const close = () => (closing ??= (async () => { + const errors = []; + for (const cleanup of cleanups.reverse()) { try { await cleanup(); } catch { errors.push('cleanup failed'); } } + if (errors.length) throw new Error('acceptance resource cleanup failed'); +})()); +for (const [name, status] of [['SIGTERM', 143], ['SIGINT', 130]]) + process.once(name, () => { void close().finally(() => process.exit(status)); }); async function main(root) { - // Her side: a work repo in her layout's shape, cloned bare into the fake Gitea. const work = join(root, 'her', 'books'); mkdirSync(work, { recursive: true }); git(work, 'init', '-q', '-b', 'main'); - for (const [path, text] of Object.entries(BASE)) { - mkdirSync(dirname(join(work, path)), { recursive: true }); - writeFileSync(join(work, path), text); + const files = { ...BASE, [SUPPORT]: '# Eye notes\nGrey eyes are in the character record.\n' }; + for (const [path, text] of Object.entries(files)) { + mkdirSync(dirname(join(work, path)), { recursive: true }); writeFileSync(join(work, path), text); } - git(work, 'add', '-A'); - git(work, 'commit', '-qm', 'her first commit'); + git(work, 'add', '-A'); git(work, 'commit', '-qm', 'her first commit'); const first = git(work, 'rev-parse', 'HEAD'); const tls = scratchCa(join(root, 'ca')); - const gitea = await fakeGitea({ dir: join(root, 'gitea'), tls, user: USER, token: TOKEN }); + const gitea = await fakeGitea({ dir: join(root, 'gitea'), tls, user: USER, token: FETCH_TOKEN, + users: { [USER]: FETCH_TOKEN, 'acme-bean': SEAT_TOKEN } }); + cleanups.push(() => gitea.close()); const remote = gitea.repo('writer', 'books'); mkdirSync(dirname(remote), { recursive: true }); git(root, 'clone', '-q', '--bare', '--no-local', work, remote); - // Her next commit, pushed to the bare repo; `from` rewinds first, which makes a force-push. - const push = (files, { from = null } = {}) => { + const push = (changed, { from = null } = {}) => { if (from) git(work, 'reset', '-q', '--hard', from); - for (const [path, text] of Object.entries(files)) { - mkdirSync(dirname(join(work, path)), { recursive: true }); - writeFileSync(join(work, path), text); + for (const [path, text] of Object.entries(changed)) { + mkdirSync(dirname(join(work, path)), { recursive: true }); writeFileSync(join(work, path), text); } - git(work, 'add', '-A'); - git(work, 'commit', '-qm', 'her next commit'); + git(work, 'add', '-A'); git(work, 'commit', '-qm', 'her next commit'); git(work, 'push', '-q', '--force', remote, 'HEAD:refs/heads/main'); return git(work, 'rev-parse', 'HEAD'); }; const remoteHead = () => git(remote, 'rev-parse', 'refs/heads/main'); - - // Our side: a scratch data root and config dir with the slot, and a bus with one creative instance. - const dataRoot = join(root, 'data'), configDir = join(root, 'config'); - mkdirSync(dataRoot, { mode: 0o700 }); - mkdirSync(join(configDir, 'secrets', BUSINESS), { recursive: true, mode: 0o700 }); - writeFileSync(join(configDir, 'secrets', BUSINESS, library.SLOT), TOKEN + '\n', { mode: 0o600 }); - const entry = { ...mappingEntry(), remote: `${gitea.origin}/writer/books.git`, user: USER }; - const business = { - id: BUSINESS, human: 'jason', arbiters: { technical: 'bean', delivery: 'bean' }, - roles: { bean: { definition: 'companion', authority: companion.authority } }, - definitions: { companion: { messaging: companion.messaging } }, - projects: { books: entry }, + const entry = { ...mappingEntry(), remote: `${gitea.origin}/writer/books.git`, user: USER, editor: EDITOR }; + entry.universes.harbor.supporting = ['Book One/Notes/']; + const f = await launchFixture(root, entry, { fetchToken: FETCH_TOKEN, seatToken: SEAT_TOKEN, ca: tls.ca }); + cleanups.push(() => f.close()); + const { runtime: bus, dataRoot } = f, broker = bus.broker; + const human = broker.bindHuman({ business: BUSINESS, human: 'jason', via: 'cli', outsideAgent: true }); + const call = (cap, verb, args = {}) => Promise.resolve().then(() => broker.request(cap, { verb, args })); + const humanCall = (verb, args = {}) => call(human, verb, args); + const sync = async (args = {}) => { + for (let i = 0; ; i++) { + try { return await humanCall('library.sync', { project: 'books', ...args }); } + catch (e) { if (e?.code !== 'library-sync-busy' || i >= 100) throw e; await new Promise(r => setTimeout(r, 100)); } + } }; - // syncCommand's argv with the scratch CA added, as packages/library/tests/sync.test.mjs does. The - // timer's period is long, so only its pass at start and the steps' own syncs fetch. - const command = (kind, url, opts) => { - const c = library.syncCommand(kind, url, opts); - return { argv: c.argv, env: { ...c.env, GIT_SSL_CAINFO: tls.ca } }; - }; - const bus = await startBroker({ dataRoot, businesses: { [BUSINESS]: business }, librarySync: { command, intervalMs: 3_600_000, configDir } }); - try { - const broker = bus.broker; - const human = broker.bindHuman({ business: BUSINESS, human: 'jason', via: 'cli', outsideAgent: true }); - const call = (cap, verb, args = {}) => Promise.resolve().then(() => broker.request(cap, { verb, args })); - // The timer's pass at start may still hold the mirror; a step's sync waits it out. - const sync = async (args = {}) => { - for (let i = 0; ; i++) { - try { return await call(human, 'library.sync', { project: 'books', ...args }); } - catch (e) { if (e?.code !== 'library-sync-busy' || i > 100) throw e; await new Promise(r => setTimeout(r, 100)); } - } - }; - const refusals = () => readRefusals(dataRoot); + const status = () => humanCall('library.status', { project: 'books' }); + const mapping = library.checkMapping(entry); + const mirror = library.mirrorPath({ business: f.business, project: 'books', dataRoot }); + const source = async () => ({ repo: mirror, mapping, commit: (await status()).served }); - // S1: her layout and the mapping that reads it. - const mapping = library.checkMapping(entry); - await step('S1a', 'her layout classifies: a character is canon, a Playground note is playground, a top-level note is unmapped', () => { - const of = p => library.classify(mapping, p); - expect(of('Book One/Characters/Mara Vell.md').scope === 'canon' && of('Book One/Characters/Mara Vell.md').type === 'character', 'character'); - expect(of('Book One/Chapters/Chapter 01.md').type === 'manuscript', 'chapter'); - expect(of('Book One/Playground/What If.md').scope === 'playground', 'playground'); - expect(of('Inbox Note.md').scope === 'unmapped', 'inbox note'); - expect(of('Second World/Chapters/01.md').universe === 'second', 'second universe'); - }); - skip('S1b', [283], 'a supporting folder in the mapping and the editor field'); + await step('S1a', 'her bare layout classifies canon, manuscript, playground, unmapped and second-universe paths', () => { + expect(git(remote, 'rev-parse', '--is-bare-repository') === 'true', 'remote is not bare'); + const of = p => library.classify(mapping, p); + expect(of(CHARACTER).scope === 'canon' && of(CHARACTER).type === 'character', 'character'); + expect(of('Book One/Chapters/Chapter 01.md').type === 'manuscript', 'chapter'); + expect(of('Book One/Playground/What If.md').scope === 'playground', 'playground'); + expect(of('Inbox Note.md').scope === 'unmapped', 'inbox'); + expect(of('Second World/Chapters/01.md').universe === 'second', 'second universe'); + }); + await step('S1b', 'a supporting folder and her editor are part of the checked mapping', async () => { + expect(library.classify(mapping, SUPPORT).scope === 'supporting', 'supporting scope'); + expect(library.supportingPath(mapping, 'harbor', WRITE, await library.listTree(remote, first)) === WRITE, 'supporting write path'); + expect(mapping.editor.name === EDITOR.name && mapping.editor.email === EDITOR.email, 'editor'); + }); + await step('S2a', 'library.sync fetches her bare repo and serves its head', async () => { + const state = await sync(); + expect(state.served === first && state.served === remoteHead(), 'wrong served commit'); + expect(state.lastError === null && state.diverged === null, 'sync error or divergence'); + }); + await step('S2b', 'library.file reads canon and playground at served, and refuses an unmapped file', async () => { + const canon = await humanCall('library.file', { project: 'books', path: CHARACTER }); + expect(canon.commit === first && canon.content.includes(MARKER), 'canon file'); + const play = await humanCall('library.file', { project: 'books', path: 'Book One/Playground/What If.md' }); + expect(play.content.includes(MARKER), 'playground file'); + await refuses(humanCall('library.file', { project: 'books', path: 'Inbox Note.md' }), 'project-read-path'); + }); + await step('S2c', 'her fast-forward push is served by the next sync', async () => { + const next = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nSalt and tar. ${MARKER}\n` }); + expect((await sync()).served === next, 'fast-forward not served'); + }); + await step('S2d', 'fixture credentials occur nowhere under the data root, including the mirror', () => { + expect(holdsToken(dataRoot) === null, 'credential found under the data root'); + }); - // S2: sync from her bare repo over https, and read at the served commit. - await step('S2a', 'library.sync fetches her bare repo and serves its head', async () => { - const r = await sync(); - expect(r.served === remoteHead() && r.served === first, `served ${r.served}, remote ${remoteHead()}`); - expect(r.lastError === null && r.diverged === null, 'state has an error or a divergence'); - }); - await step('S2b', 'library.file reads her canon and playground notes at the served commit, and refuses an unmapped one', async () => { - const canon = await call(human, 'library.file', { project: 'books', path: 'Book One/Characters/Mara Vell.md' }); - expect(canon.commit === first && canon.content.includes(MARKER), 'canon note'); - const play = await call(human, 'library.file', { project: 'books', path: 'Book One/Playground/What If.md' }); - expect(play.content.includes(MARKER), 'playground note'); - await refuses(call(human, 'library.file', { project: 'books', path: 'Inbox Note.md' }), 'project-read-path'); - }); - await step('S2c', 'her next commit is a fast-forward and the next sync serves it', async () => { - const next = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nSalt and tar. ${MARKER}\n` }); - const r = await sync(); - expect(r.served === next, `served ${r.served}, expected ${next}`); - }); - await step('S2d', 'the token is in the slot only: nowhere under the data root, the mirror included', () => { - const file = holdsToken(dataRoot); - expect(file === null, `token found under the data root (${file === null ? '' : file.slice(dataRoot.length)})`); - }); + let thread, asked, reply, writeCommit, writeNotice; + let citedCommit; + await step('S3a', 'she makes a creative thread and asks in it', async () => { + const project = (await humanCall('project.create', { name: 'books', repo: 'books' })).id; + thread = (await humanCall('thread.create', { project, name: 'Book One', instance: 'bean' })).id; + const looked = await library.get(await source(), { universe: 'harbor', path: CHARACTER }); + expect(looked && looked.scope === 'canon', 'lookup missing'); + const index = looked.lines.findIndex(line => line.includes('Grey eyes.')); + expect(index >= 0, 'lookup quote missing'); + citedCommit = looked.commit; + const cite = { path: CHARACTER, commit: citedCommit, lines: [index + 1, index + 1], quote: 'Grey eyes.', as: 'canon' }; + const valid = text => JSON.stringify({ text, citations: [cite] }); + const bad = JSON.stringify({ text: 'Playground is canon.', citations: [{ path: 'Book One/Playground/What If.md', + commit: citedCommit, lines: [4, 4], quote: MARKER, as: 'canon' }] }); + f.setPlan([{ text: valid('Grey eyes.') }, { text: bad }, { text: valid('The playground cannot establish canon.') }]); + asked = (await humanCall('thread.send', { thread, body: "What colour are Mara Vell's eyes?" })).id; + }); + await step('S3b', 'the real launcher runs the C2-06 fake harness and stores its reply in her thread', async () => { + reply = await until(async () => (await humanCall('thread.messages', { thread })).find(m => m.in_reply_to === asked && m.class === 'RESULT'), 'launcher reply'); + expect(reply.from_role === 'bean' && f.cap(thread), 'reply did not use managed bean run'); + const record = f.records.find(r => r.thread === thread); + expect(record && Number.isSafeInteger(record.pid) && record.pid > 1, 'no launched process record'); + const runDir = dirname(join(dataRoot, 'launches', BUSINESS, record.run, 'session.json')); + expect(Number(readFileSync(join(runDir, 'fixture-count'), 'utf8')) === 1, 'scripted adapter was not called exactly once'); + }); + await step('S3c', 'her stored reply carries a verified lookup citation at the served commit', () => { + const body = JSON.parse(reply.body); + expect(body.text === 'Grey eyes.' && body.citations[0].commit === citedCommit, 'wrong reply'); + expect(reply.check?.citations?.length === 1 && reply.check.citations[0].scope === 'canon', 'missing stored guard check'); + const events = readEvents(dataRoot, "kind='action.allowed'"); + expect(events.some(e => e.action === 'message.checked' && e.message === reply.id && e.check.citations[0].scope === 'canon'), 'no stored check event'); + }); + await step('S4', 'a playground-as-canon reply refuses, and the runner retries without storing the bad body', async () => { + const next = (await humanCall('thread.send', { thread, body: 'Can the playground establish canon?' })).id; + const second = await until(async () => (await humanCall('thread.messages', { thread })).find(m => m.in_reply_to === next && m.class === 'RESULT'), 'guard retry'); + expect(JSON.parse(second.body).text === 'The playground cannot establish canon.', 'retry answer'); + const history = await humanCall('thread.messages', { thread }); + expect(!history.some(m => m.body.includes('Playground is canon.')), 'refused reply persisted'); + expect(readEvents(dataRoot).some(e => e.code === 'playground-cited-as-canon'), 'missing refusal'); + const record = f.records.find(r => r.thread === thread); + expect(Number(readFileSync(join(dataRoot, 'launches', BUSINESS, record.run, 'fixture-count'), 'utf8')) === 3, 'retry count'); + }); + await step('S5a', 'her force-push holds library-diverged without moving served', async () => { + const before = (await status()).served; + const forced = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nRewritten. ${MARKER}\n` }, { from: first }); + await refuses(sync(), 'library-diverged'); + const state = await status(); + expect(state.served === before && state.diverged?.commit === forced, 'force-push was not held'); + expect(readEvents(dataRoot).some(e => e.code === 'library-diverged'), 'missing divergence refusal'); + }); + await step('S5b', 'a stale accept refuses, then her accepted tip becomes served', async () => { + await refuses(sync({ accept: first }), 'library-accept-stale'); + const tip = remoteHead(), state = await sync({ accept: tip }); + expect(state.served === tip && state.diverged === null, 'tip not accepted'); + }); - // S3: she asks the creative instance in its thread; the fake harness answers. - let thread, asked; - await step('S3a', 'she makes a thread with the creative instance and asks in it', async () => { - const project = (await call(human, 'project.create', { name: 'books' })).id; - const made = await call(human, 'thread.create', { project, name: 'Book One', instance: 'bean' }); - expect(made.instance === 'bean', `instance ${made.instance}`); - thread = made.id; - asked = (await call(human, 'thread.send', { thread, body: 'What colour are Mara Vell\'s eyes?' })).id; + // Pending on row288, not stubs or accepted skips. These calls become runnable only after its landing. + await step('S6', 'the fixture-granted seat writes one supporting commit with its trailer and a thread notice', async () => { + const before = remoteHead(); + await call(f.cap(thread), 'library.doc.write', { universe: 'harbor', path: WRITE, base: null, content: '# Agent note\nA supporting observation.\n' }); + writeCommit = remoteHead(); + expect(writeCommit !== before && git(remote, 'rev-list', '--count', `${before}..${writeCommit}`) === '1', 'write must add exactly one commit'); + expect(git(remote, 'show', '-s', '--format=%P', writeCommit) === before, 'write parent'); + expect(git(remote, 'show', '-s', '--format=%B', writeCommit).includes('Mosaic-Agent: acme/bean'), 'missing agent trailer'); + expect(git(remote, 'show', '-s', '--format=%an <%ae>', writeCommit) === 'Bean fixture ', 'seat author'); + expect(gitea.requests.some(r => r.method === 'POST' && r.user === 'acme-bean' && r.path.endsWith('/git-receive-pack')), 'seat credential not used for push'); + writeNotice = (await humanCall('thread.messages', { thread })).find(m => { + try { const body = JSON.parse(m.body); return body.notice === 'library-write' && body.commit === writeCommit; } catch { return false; } }); - await step('S3b', 'the fake harness receives her message on its thread and its reply is stored in her thread', async () => { - const run = broker.bindLaunch({ business: BUSINESS, role: 'bean', run: 'bean-c2-14', harness: 'pi', thread }); - await call(run, 'role.claim'); - const inbox = await call(run, 'message.receive'); - expect(inbox.length === 1 && inbox[0].id === asked, `received ${inbox.length}`); - await call(run, 'message.send', { to: 'human', body: 'Grey. (Book One/Characters/Mara Vell.md)', in_reply_to: asked }); - const history = await call(human, 'thread.messages', { thread }); - expect(history.length === 2 && history[1].from_role === 'bean' && history[1].in_reply_to === asked, - `history ${history.map(m => m.from_role).join(',')}`); - }); - skip('S3c', [216, 217], 'the reply carries cites from the lookups, the guard verifies each one at the served commit, and the store keeps them'); - skip('S4', [217], 'a reply citing a playground note with "as": "canon" refuses playground-cited-as-canon'); - - // S5: a force-push holds as library-diverged until she accepts the new tip. - await step('S5a', 'a force-push of her branch holds as library-diverged and served stays put', async () => { - const before = (await call(human, 'library.status', { project: 'books' })).served; - const forced = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nRewritten. ${MARKER}\n` }, { from: first }); - await refuses(sync(), 'library-diverged'); - const status = await call(human, 'library.status', { project: 'books' }); - expect(status.served === before, 'served moved'); - expect(status.diverged?.commit === forced, `diverged ${JSON.stringify(status.diverged)}`); - expect(refusals().includes('library-diverged'), 'no recorded refusal'); - }); - await step('S5b', 'accept refuses a stale commit, then serves the new tip and clears the hold', async () => { - await refuses(sync({ accept: first }), 'library-accept-stale'); - const tip = remoteHead(); - const r = await sync({ accept: tip }); - expect(r.served === tip && r.diverged === null, `served ${r.served}`); - }); - - // S6-S9: agent writes, the story-path refusal, her revert and her Console save. - skip('S6', [283, 285, 286, 288], 'a seat with a fixture grant writes a supporting doc: one commit with the trailer in the bare repo, and the notice in her thread'); - skip('S7', [283, 288], 'a write to a story path refuses library-write-story-path, and the bare repo\'s ref is unchanged'); - skip('S8', [288], 'her revert of the write lands as one commit'); - skip('S9', [283, 288], 'her library.file.save lands as one commit with her editor as author'); - // S10: the Console. - skip('S10', [220, 225], 'the Console renders the reply and the notice'); - } finally { - await bus.close(); - await gitea.close(); - } -} - -// The codes of the bus's recorded refusals, oldest first, read from its store on a read-only handle. -function readRefusals(dataRoot) { - const db = new DatabaseSync(join(dataRoot, 'bus', 'bus.sqlite'), { readOnly: true }); - try { return db.prepare("SELECT body FROM events WHERE kind='action.refused' ORDER BY seq").all().map(row => JSON.parse(row.body).code); } - finally { db.close(); } + expect(writeNotice?.from_role === 'bean', 'notice missing from her thread'); + const noticeBody = JSON.parse(writeNotice.body); + expect(Object.keys(noticeBody).sort().join(',') === 'commit,notice,op,path' && noticeBody.notice === 'library-write' + && noticeBody.op === 'write' && noticeBody.path === WRITE && noticeBody.commit === writeCommit, 'notice body'); + }); + await step('S7', 'a story-path write refuses library-write-story-path and leaves her remote unchanged', async () => { + const before = remoteHead(), served = (await status()).served; + await refuses(call(f.cap(thread), 'library.doc.write', { universe: 'harbor', path: 'Book One/Chapters/Chapter 01.md', base: null, content: 'Unwanted story edit.\n' }), 'library-write-story-path'); + expect(remoteHead() === before && (await status()).served === served, 'refusal moved a ref'); + }); + await step('S8', 'her revert of the agent write adds exactly one editor-authored commit', async () => { + const before = remoteHead(); + // A2 (sage-3596): revert names the business project key as well as the commit. + const result = await humanCall('library.revert', { project: 'books', commit: writeCommit }); + const after = remoteHead(); + expect(after !== before && git(remote, 'rev-list', '--count', `${before}..${after}`) === '1', 'revert commit count'); + expect(git(remote, 'show', '-s', '--format=%P', after) === before, 'revert must have exactly one parent'); + expect(result.reverted === writeCommit && result.commit === after, 'revert result'); + expect(!git(remote, 'ls-tree', '-r', '--name-only', after).split('\n').includes(WRITE), 'revert did not remove the newly created note'); + expect(git(remote, 'show', '-s', '--format=%an <%ae>', after) === `${EDITOR.name} <${EDITOR.email}>`, 'revert editor'); + }); + await step('S9', 'her library.file.save adds one commit with her editor as author', async () => { + const before = remoteHead(), base = git(remote, 'rev-parse', `${before}:${CHARACTER}`); + const content = git(remote, 'show', `${before}:${CHARACTER}`) + '\nHer saved note.\n'; + await humanCall('library.file.save', { path: CHARACTER, base, content }); + const after = remoteHead(); + expect(after !== before && git(remote, 'rev-list', '--count', `${before}..${after}`) === '1', 'save commit count'); + expect(git(remote, 'show', '-s', '--format=%P', after) === before, 'save must have exactly one parent'); + expect(git(remote, 'show', '-s', '--format=%an <%ae>', after) === `${EDITOR.name} <${EDITOR.email}>`, 'save editor'); + expect(git(remote, 'show', `${after}:${CHARACTER}`).endsWith('Her saved note.'), 'save content'); + }); + await step('S10', 'the real Console renders her checked reply and the broker write notice', async () => { + expect(writeNotice && writeCommit, 'row288 write notice is required'); + await consoleAcceptance({ humanCall, thread, reply, writeNotice, writeCommit, cleanups }); + }); + expect(holdsToken(dataRoot) === null, 'credential persisted during the later steps'); } const root = process.argv[2]; if (!root) { process.stderr.write('usage: node scripts/case2/e2e.mjs \n'); process.exit(2); } try { await main(resolve(root)); } -catch (e) { process.stderr.write(`e2e: stopped: ${e?.code ?? ''} ${String(e?.message ?? e).split('\n')[0]}\n`); process.exitCode = 1; } +catch (e) { failures++; process.stderr.write(redact(`e2e: stopped: ${e?.code ?? ''} ${String(e?.message ?? e).split('\n')[0]}`) + '\n'); } +finally { await close(); } +if (failures) process.exitCode = 1; diff --git a/scripts/case2/launch.mjs b/scripts/case2/launch.mjs new file mode 100644 index 000000000..6169538d2 --- /dev/null +++ b/scripts/case2/launch.mjs @@ -0,0 +1,120 @@ +// C2-14 fixture: the real host/launcher with C2-06's scripted citation adapter. No model binary runs. +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { BusError, writeGrantEvent } from '../../packages/bus/src/broker.mjs'; +import { Store } from '../../packages/bus/src/store.mjs'; +import { startBroker } from '../../packages/bus/src/runtime.mjs'; +import { grantDecision } from '../../packages/bus/tests/host-fixtures.mjs'; +import { launchDoc } from '../../packages/business/tests/helpers.mjs'; +import { fakeHarnesses, fixture } from '../../packages/cli/tests/helpers.mjs'; +import { bootConfig, loadSystem } from '../../packages/cli/src/config.mjs'; +import { createLauncher } from '../../packages/cli/src/launcher.mjs'; +import { SESSION_CREDENTIALS } from '../../packages/seat/src/session.mjs'; +import * as library from '../../packages/library/src/index.mjs'; + +const ADAPTER = fileURLToPath(new URL('../../packages/harness/tests/fixtures/citation-adapter.mjs', import.meta.url)); +const ROTATE = '2099-01-01'; +const AT = '2026-10-24T00:00:00.000Z'; +const shell = value => "'" + String(value).replaceAll("'", "'\\''") + "'"; + +// A fixture grant is part of the brief. It uses the normal store shape; no executor/network mint or +// role-policy modification occurs. This is deliberately distinct from the row287 grant acceptance. +function seedGrant(dataRoot, storeRoot, token) { + const dir = join(storeRoot, 'acme', 'bean'); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + writeFileSync(join(dir, 'gitea'), token + '\n', { mode: 0o600 }); + const store = new Store(dataRoot); + try { + const decision = grantDecision(store, { business: 'acme', grant: { + method: 'gitea', agent: 'bean', target: { projects: ['books'] }, rotateBy: ROTATE, + } }); + store.run('INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(?,?,?,?,?,?)', + 'acme', 'bean', 'acme-bean', 1, '2270227022702270', decision); + store.run('INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?)', + 'acme', 'bean', 'acme-bean', 1, 'fixture-227', '["books"]', ROTATE, decision); + writeGrantEvent(store, AT, 'acme', { operation: 'grant.gitea', decision }, decision); + writeGrantEvent(store, AT, 'acme', { action: 'grant.applied', method: 'gitea', agent: 'bean', + grantDecision: decision, user: 'acme-bean', userId: 1, tokenId: 1, forks: [], + repositories: [{ project: 'books', repository: 'fixture', level: 'write' }] }, 'bean'); + } finally { store.close(); } +} + +export async function launchFixture(root, entry, { fetchToken, seatToken, ca }) { + const configDir = join(root, 'config'), storeRoot = join(configDir, 'secrets', 'agents'); + const slotDir = join(configDir, 'secrets', 'acme'); + mkdirSync(slotDir, { recursive: true, mode: 0o700 }); + for (const slot of [library.SLOT, library.HUMAN_SLOT]) + writeFileSync(join(slotDir, slot), fetchToken + '\n', { mode: 0o600 }); + const plan = join(root, 'citation-plan.json'); + writeFileSync(plan, '[]\n', { mode: 0o600 }); + const f = fixture(root, 'acme', doc => { + // The unused factory roles remain valid, but no tracker is configured or polled. + delete doc.tracker; + doc.projects = { books: entry }; + doc.roles.chief.credentials = { gitea: { file: join(slotDir, library.SLOT), rotateBy: ROTATE } }; + doc.roles.bean = { definition: 'companion', + credentials: { gitea: { store: true, rotateBy: ROTATE } }, + identity: { name: 'Bean fixture', email: 'bean@example.test', gitea: { url: new URL(entry.remote).origin, user: 'acme-bean' } }, + publish: ['books'], vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } }; + doc.launch = { ...launchDoc('chief', ['bean'], { opus: 1, sonnet: 1 }), threadIdleSeconds: 3600 }; + doc.plugins = { ...doc.plugins, channel: ['webui'] }; + doc.replyLimits = { requireCitation: true, citationsPerReply: 20 }; + return doc; + }); + chmodSync(configDir, 0o700); + for (const key of SESSION_CREDENTIALS.flat()) delete f.env[key]; + seedGrant(f.dataRoot, storeRoot, seatToken); + const system = loadSystem({ env: f.env }); + const { trackers, ...boot } = bootConfig({ system, businessId: 'acme', env: f.env }); + if (trackers !== undefined) throw new Error('acceptance fixture must not configure tracker polling'); + const command = (kind, url, opts) => { + const c = library.syncCommand(kind, url, opts); + return { argv: c.argv, env: { ...c.env, GIT_SSL_CAINFO: ca } }; + }; + const runtime = await startBroker({ ...boot, librarySync: { command, intervalMs: 3_600_000, configDir } }); + const hooks = [], cleanups = [], caps = new Map(), records = [], logs = []; + let closing = null; + const close = () => (closing ??= (async () => { + try { for (const hook of hooks) await hook(0); } + finally { + try { await runtime.close(); } + finally { for (const cleanup of cleanups.reverse()) await cleanup(); } + } + })()); + try { + const ops = { + identity: m => runtime.identity(m.cap), authorizeLaunch: m => runtime.authorizeLaunch(m.cap, m.instance), + refuse: m => runtime.refuse(m.cap, m.code, m.subject), endLaunch: m => runtime.endLaunch(m.record), + credentialStatus: m => runtime.credentialStatus(m.business, m.role), threadsState: m => runtime.threadsState(m.business), + threadIdle: m => runtime.threadIdle(m.record), mayLaunch: m => runtime.mayLaunch(m), + landHeld: m => runtime.landHeld(m.record), landHolders: m => runtime.landHolders(m.business), + landRelease: m => runtime.landRelease(m.record), sessionClears: m => runtime.sessionClears(m.business), + lifecycleRequests: m => runtime.lifecycleRequests(m.business), + }; + const wrap = fn => async value => { + try { return fn(value); } + catch (e) { throw Object.assign(new Error('fixture host refused'), { code: e instanceof BusError ? e.code : 'fixture-host-refused' }); } + }; + const host = { business: 'acme', path: runtime.path, dataRoot: f.dataRoot, + op: wrap(m => { if (!Object.hasOwn(ops, m.op)) throw new BusError('invalid-host-request'); return ops[m.op](m); }), + bindLaunch: wrap(record => { + const bound = runtime.bindLaunch(record); + records.push(record); caps.set(record.thread, bound.cap); + return bound; + }), + beforeClose: fn => void hooks.push(fn), + }; + const adapter = join(root, 'citation-adapter.sh'); + writeFileSync(adapter, `cp ${shell(plan)} "$(dirname "$MOSAIC_TURN_MARKER")/fixture-steps.json"\nexec ${shell(process.execPath)} ${shell(ADAPTER)}\n`, { mode: 0o644 }); + const launcher = createLauncher({ host, system, env: f.env, + makeLoader: await fakeHarnesses({ after: fn => cleanups.push(fn) }, { pi: adapter }, write => + write('channel', 'webui', `export * from ${JSON.stringify(new URL('../../packages/plugins/channel/webui/index.mjs', import.meta.url).href)};`)), + readOnly: [ADAPTER, plan], harnessVersion: '0.85.1', + sessionDefaults: { pollInterval: 50 }, threadTickMs: 50, log: line => logs.push(line), + }); + await launcher.listen(); + return { ...f, runtime, launcher, logs, records, plan, cap: thread => caps.get(thread), close, + business: boot.businesses.acme, setPlan: steps => writeFileSync(plan, JSON.stringify(steps) + '\n') }; + } catch (e) { await close(); throw e; } +} diff --git a/scripts/test-case2.sh b/scripts/test-case2.sh index 8588ef205..8aab34cef 100755 --- a/scripts/test-case2.sh +++ b/scripts/test-case2.sh @@ -1,94 +1,62 @@ #!/usr/bin/env bash -# Case 2 end-to-end acceptance on a scratch root (C2-14, #1728, row 227). -# -# Her layout in a bare repo behind a fake Gitea on 127.0.0.1 (TLS, scratch CA), -# a bus on a scratch data root with one creative instance, and the steps of the -# C2-14 brief in order. scripts/case2/e2e.mjs runs the steps in one process, -# since the remote lives only as long as that process does, and prints one line -# per step. This script checks its syntax, runs it, and counts the lines. -# -# No live host, no real model, no network past 127.0.0.1, no real key. The -# human is an in-process stand-in: human-cli.mjs refuses a caller with an agent -# among its ancestors, so a seat can't drive it. The creative instance's run is -# a fake harness bound by the driver, with no launcher. -# -# A step that waits on a row not yet on refactor prints a skip line naming the -# row, and counts as neither pass nor fail. When the row lands, its step gets -# written in the driver and its skip line goes. -# -# scripts/test-case2.sh full run -# NO_COLOR=1 scripts/test-case2.sh plain output +# C2-14 (#1728, row227): one acceptance run on a scratch root, no skipped steps. +# Entry point for the real launcher/runner, C2-06 scripted adapter, fake TLS Gitea and Console DOM. +# S6-S10 wait on row288's landing; do not execute this draft before Sage's CLEAR (sage-4167). +# The only human stand-in is bindHuman in-process; a development agent cannot prove human CLI ancestry. set -uo pipefail cd "$(dirname "$0")/.." - SANDBOX="$(mktemp -d)" -trap 'rm -rf "$SANDBOX"' EXIT - -PASS=0 -FAIL=0 -SKIP=0 +DRIVER_PID='' +cleanup() { + local result=$? + trap - EXIT INT TERM + if [ -n "$DRIVER_PID" ]; then + kill -TERM "$DRIVER_PID" 2>/dev/null || true + wait "$DRIVER_PID" 2>/dev/null || true + fi + rm -rf "$SANDBOX" + exit "$result" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +PASS=0 FAIL=0 if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m' else - C_OK=""; C_FAIL=""; C_RESET="" + C_OK=''; C_FAIL=''; C_RESET='' fi - check() { - if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi + if [ "$2" = 0 ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1" + else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi } - -DRIVER="scripts/case2/e2e.mjs" -# The steps the driver must print, in order. A missing or extra line fails. -STEPS="S1a S1b S2a S2b S2c S2d S3a S3b S3c S4 S5a S5b S6 S7 S8 S9 S10" - -echo "toolchain: node $(node --version), git $(git --version | cut -d' ' -f3), openssl $(openssl version | cut -d' ' -f2)" -echo - -# --- syntax --- +DRIVER=scripts/case2/e2e.mjs +STEPS='S1a S1b S2a S2b S2c S2d S3a S3b S3c S4 S5a S5b S6 S7 S8 S9 S10' node --check "$DRIVER" >/dev/null 2>&1 -check "syntax: $DRIVER" $? - -# --- the steps --- -# The driver's own state stays in the sandbox. Git reads no global or system -# config, so a caller's hooks, signing or credential helpers can't reach it. -OUT="$SANDBOX/steps.txt" -ERR="$SANDBOX/stderr.txt" -GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \ - node "$DRIVER" "$SANDBOX/root" >"$OUT" 2>"$ERR" +check "syntax: $DRIVER" "$?" +OUT="$SANDBOX/steps.txt" ERR="$SANDBOX/stderr.txt" +GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 node "$DRIVER" "$SANDBOX/root" >"$OUT" 2>"$ERR" & +DRIVER_PID=$! +wait "$DRIVER_PID" RC=$? +DRIVER_PID='' check "driver ran to the end (exit $RC)" "$RC" -if [ "$RC" -ne 0 ]; then - # The driver prints codes and first lines only; never git's stderr with a token in it. - grep '^e2e: ' "$ERR" | head -5 -fi - -seen="" +if [ "$RC" -ne 0 ]; then grep '^e2e: ' "$ERR" | head -5; fi +seen='' while IFS= read -r line; do case "$line" in - "ok "*) - id="${line#ok }"; id="${id%% *}" - check "${line#ok }" 0 ;; - "not ok "*) - id="${line#not ok }"; id="${id%% *}" - check "${line#not ok }" 1 ;; - "skip "*) - id="${line#skip }"; id="${id%% *}" - SKIP=$((SKIP+1)); echo "skip ${line#skip }" ;; - *) - id="" - check "driver line not understood: ${line:0:200}" 1 ;; + 'ok '*) id="${line#ok }"; id="${id%% *}"; check "${line#ok }" 0 ;; + 'not ok '*) id="${line#not ok }"; id="${id%% *}"; check "${line#not ok }" 1 ;; + *) id=''; check 'driver line not understood (a skip cannot pass acceptance)' 1 ;; esac [ -n "$id" ] && seen="$seen $id" done <"$OUT" [ "${seen# }" = "$STEPS" ] -check "the driver printed each step once, in order" $? - -# --- the token --- -# The made-up token is in the slot and nowhere else the bus writes. The driver -# checks the data root; this checks its output too. -! grep -rqF 'fixture-token-c2-14-made-up-0227' "$OUT" "$ERR" -check "the fixture token is in no driver output" $? - -echo -echo "selftest: $PASS passed, $FAIL failed, $SKIP skipped (each skip names the row it waits on)" +check 'every acceptance step ran once, in order, with nothing skipped' "$?" +if grep -qF -e 'fx227-fetch-q7' -e 'fx227-seat-q7' "$OUT" "$ERR"; then + check 'fixture credentials are in no driver output' 1 +else + check 'fixture credentials are in no driver output' 0 +fi +printf '\nselftest: %s passed, %s failed, 0 skipped\n' "$PASS" "$FAIL" [ "$FAIL" -eq 0 ]