diff --git a/agents/della/work/queue-224/BUILD.md b/agents/della/work/queue-224/BUILD.md new file mode 100644 index 000000000..8c7faf6f3 --- /dev/null +++ b/agents/della/work/queue-224/BUILD.md @@ -0,0 +1,141 @@ +# Row 224 — C2-05b broker lookups and typed tools (#1725) + +Owner: Della; reviewer: Dewey. Assignment sage-2795; scope extension sage-2804. +Base: `4f3fbc25a` (Researcher C2-05a). Branch: `della/row224`. +Brief: C2-05b in `docs/plans/2026-10-20_case2-rows.md` at `aca16404c` +and `51cc0445c`; design section 3, 5.3 M3 and 7.2; C2-05a BUILD rulings. + +## Execution boundary and progress + +Code/docs and local Git only while row 253 holds the gate. No Node (including +syntax checks), HTTPS Git, queue claim, test or push until Sage's CLEAR. +Tests are written first; their RED/GREEN runs are deferred, not claimed. +Queue next is deferred too: its executable uses Node. Sage assigned this row +explicitly. After 253 and 216 land, rebase under a new branch name. + +- Pre-flight: query.mjs consumes `{repo,commit,mapping}`; broker cannot import + Git/filesystem library code because it is in the sandbox import closure. + Runtime injects the host lookup adapter; existing human file/status/sync stay. +- Pre-flight: resolved has no project list and a library is never a checkout. + Bundle needs an explicit library flag from launcher business configuration. +- M3 reading: exact creative base is five lookups plus send_message; retain + explicitly granted broker web_search/web_fetch for research/companion under + landed C2-08 and sage-2804. No generic spec, task, evidence or decision tools. Residual (mos-1308): those + reads lack broker action checks; their typed-tool absence is the only fence + for creative roles. M1/M2 deny bash; broker-side refusal is outside this row. + Mos confirmed this interpretation in mos-1308; design wording fix belongs to Gosalyn. Other definitions retain their current tools. +- Multi-library businesses use an optional project ID; omit it only with one + configured library. Universe is required for all five tools, including get. +- Missing library uses the brief's `library-not-configured`; argument errors + reuse `invalid-request` and C2-05a query codes. No new action or authority. + +## Manifest (draft code, unrun) + +- bus/src/broker.mjs: five lookup verbs, strict args, holder/business rechecks. +- bus/src/runtime.mjs: host lookup adapter, stale-before-read sync, served pin. +- harness/src/tools.mjs: five schemas; creative exact-set filtering. +- harness/src/bundle.mjs: library flag forwarded to typedTools. +- cli/src/launcher.mjs: one flag derived from trusted business projects. +- bus/tests/library-lookups.test.mjs: real query/broker/socket/runtime cases. +- harness/tests/library-tools.test.mjs: schemas, role snapshots, bundle parity. +- cli/tests/thread-launch.test.mjs: flag reaches a real launched bundle with no library checkout. +- agents/della/work/queue-224/BUILD.md: manifest, rulings and deferred verification ledger. + +## Verification ledger + +No test or syntax-check run yet. Acceptance requires full bus/harness suites +and every scripts/test-*.sh on Sage's gate; focused files and affected CLI/ +seat import-closure tests precede that only in an authorized CLEAR. + +## Coordination + +Huey272 reports no runtime/bundle/launcher overlap. Rocko219 changes launcher +THREAD_DEFINITIONS checks only, separate from our buildBundle flag argument. +The launcher argument is required: bundle cannot derive library presence from +checkout IDs (a library has no agent clone). No role, authority or config schema +changes; library project entries are already validated and protected by M9. + +## Mount and pin impact (inspection only) + +No new imports in broker.mjs or tools.mjs; no RUNNER_FILES or sandbox mount +addition. runtime.mjs is host-only and already imports library index.mjs. +Bundle forwards only a boolean, never a root/mapping/slot/credential. The +existing import-closure tests are bus/tests/project-runtime.test.mjs and +seat/tests/sandbox.test.mjs. Grep found no literal sha256 pin for runtime.mjs +or bundle.mjs; bundle tests derive current source digests through SOURCES. +Library sources remain in the existing whole-directory release copy. + +## Runs to schedule only after CLEAR + +Set TMPDIR=/mnt/storage/scratch/tmp. Focused new bus/harness files first, +then existing library.test.mjs, library-sync.test.mjs, project-runtime.test.mjs, +harness creative/tools/spec-tools/bundle files and affected CLI launch tests. +Follow with the required whole bus/harness suites and Sage's full shell gate. +No timing claim yet: use the repository/Sage per-file limits and record every +command, outcome, elapsed time and timeout before acceptance. No focused skip +or pattern run substitutes for whole-file gate evidence. + +## Read-through findings and residuals + +- Library get requires universe although the older design table omitted it; + section 3 isolation rule and C2-05a's accepted API are binding. +- Query type enum matches mapping.TYPES (five values); manuscript is a distinct + lookup and not a find type. Unknown host-path/commit/business/accept fields + are refused; explicit project is restricted to this business's library list. +- Async reads recheck the live holder and current project revision. A stale + read attempts sync, then takes the new served snapshot; a returned fetch + failure/divergence keeps serving the old pin. Busy sync uses the current pin. + Unreadable state, no served commit or unsafe configuration refuse. Query + and sync never hold a SQLite transaction over await. +- Processing halt refuses new agent lookup work and rechecks before returning + async results. Existing human library file/status/sync behavior is unchanged. +- Timer sync and reads still use C2-03a's 60-second contract. No timer tuning or + resource-limit schema is introduced by this row. Q222 creative reply settings + are in C2-06; no reply guard or writing capability is implemented here. +- The generic tool socket's existing 60-second client timeout can precede the + server's 120-second Git timeout. This row does not change the generic timeout; + a timed-out lookup is read-only and retryable, never a write/outcome claim. +- All tests are authored but unrun. No RED, GREEN, syntax, runtime performance, + sandbox, broker/harness suite, shell gate, claim or push evidence exists yet. + +## Candidate review checklist (read-through only) + +- [x] Five verbs mapped to real C2-05a methods, all require universe. +- [x] Default sole library or explicit owned project; no caller repo/commit. +- [x] Human-only file/status/sync gates kept; lookup holder rechecked. +- [x] Async socket dispatch includes lookup verbs through LIBRARY_VERBS. +- [x] Stale/unknown fetch syncs first; results use current immutable served pin. +- [x] Six/eight creative names per mos-1308; other roles retain their tools. +- [x] Launcher -> bundle -> typedTools passes presence without a library clone. +- [x] No imports added to sandbox source closure; no mount change. +- [ ] Execute new tests, RED characterization, full files/suites after CLEAR. +- [ ] Rebase after rows 253/216 land, under a new branch name. +- [ ] Claim/push only in Sage-authorized gap; Dewey review and Sage gate. + +Candidate preservation commits are explicitly unrun under sage-2795; they are +not completion, green-suite or landing claims. No dependencies installed. + +Correction: the first committed-range diff --check found a blank line at EOF +in the new library-tools test, missed while it was untracked. Removed in a +follow-up local commit; the complete range must be checked again. No tests ran. + +## Shared-log receipts for Sage (sage-2815) + +The shared logs are unchanged from the base in this row. Sage appends these +receipts in the landing sweep; they are preserved here, not in the manifest. + +For `BUILD-LOG.md`: + +```text +2026-10-22T16:36:30Z | Della row224 C2-05b #1725 preparation | Before: C2-05a queries exist at 4f3fbc25a but no broker lookup verbs or typed tools. Scope: broker/tools plus Sage-authorized runtime/bundle integration, tests and docs. Existing human-only library reads retained; five agent queries require a live holder and trusted configured source. Code/docs only under row253 gate; no Node/test/push/claim. Runtime ownership coordinated with Rocko219/Huey272. +``` + +For `docs/SESSIONS.md`: + +```text +2026-10-22T16:36:30Z | Della (sage-2795 / sage-2804, Q220) | Row 224 C2-05b #1725, della/row224 base 4f3fbc25a | Read both pinned briefs and C2-05a source; preparing broker/typed tools and authorized host runtime/bundle seams. No queue command, Node, HTTPS Git, claim, test or push during Gander gate. Dewey reviewer; Rocko/Huey runtime coordination sent. Tests and integration verification deferred to CLEAR. +``` + +Local-only draft pins 19c0a7adf/7cf39c390 are superseded by the +log-free candidate. Saved as local branch della/row224-draft-with-logs before +rebuilding the unpublished candidate commit; no remote history changed. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 8c6d0cdb2..83485ee07 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -101,7 +101,16 @@ export const TASK_PAGE_MAX = 200; // 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']); // 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']); +// Row 224: pure agent lookups are separate from the three human-only verbs. Listing them +// here also makes server.mjs await their host effects instead of serializing a Promise as {}. +const LIBRARY_LOOKUPS = Object.freeze({ + 'library.find': { method: 'find', fields: ['universe', 'query', 'type', 'book', 'scope', 'offset'], required: ['universe', 'query'] }, + 'library.get': { method: 'get', fields: ['universe', 'path', 'from', 'links_from'], required: ['universe', 'path'] }, + 'library.search': { method: 'search', fields: ['universe', 'text', 'book', 'scope', 'offset'], required: ['universe', 'text'] }, + 'library.manuscript': { method: 'manuscript', fields: ['universe', 'book', 'scope', 'chapter', 'from', 'to'], required: ['universe', 'book'] }, + 'library.universe': { method: 'universeInfo', fields: ['universe'], required: ['universe'] }, +}); +export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync', ...Object.keys(LIBRARY_LOOKUPS)]); export const PROJECT_SAVE_VERB = 'project.file.save'; export const PROJECT_SAVE_REQUEST_MAX = 1024 * 1024 + 64 * 1024; export const WEB_VERBS =Object.freeze(['web.search', 'web.fetch']); @@ -5352,10 +5361,42 @@ export class Broker { throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request); } } + // Row 224 C2-05b: the agent never supplies a host repo, commit or business. Its current + // holder and business entry are rechecked before/after awaits; runtime owns Git and queries. + async #requestLibraryLookup(s, { verb, args }) { + try { + this.#agent(s); + const business = this.#business(s.business); + const projects = Object.entries(business.projects ?? {}).filter(([, entry]) => entry.kind === 'library'); + if (!projects.length || typeof this.#library?.lookup !== 'function') fail('library-not-configured'); + const spec = LIBRARY_LOOKUPS[verb]; + keys(args, ['project', ...spec.fields], spec.required); + if (!text(args.universe, 160)) fail('invalid-request'); + if (args.project === undefined && projects.length !== 1) fail('invalid-request'); + const project = args.project ?? projects[0][0]; + if (!text(project, 160) || !projects.some(([name]) => name === project)) fail('library-not-configured'); + const revision = JSON.stringify(business.projects[project]); + const authorize = () => { + this.#agent(s); + this.#processingGate(s, verb); + if (JSON.stringify(this.#business(s.business).projects?.[project]) !== revision) fail('library-not-configured'); + }; + const { project: ignored, ...query } = args; + authorize(); + const result = await this.#library.lookup({ business, project }, { method: spec.method, args: query, authorize }); + authorize(); + this.#secretCheck(result); + return result; + } catch (error) { + const Failure = this.#library?.LibraryError; + throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, { verb, args }); + } + } // C2-04 M4: only a verified human reads library text or status. Host Git // functions stay injected, preserving this module's sandbox import closure. async #requestLibrary(s, request) { const { verb, args } = request; + if (Object.hasOwn(LIBRARY_LOOKUPS, verb)) return this.#requestLibraryLookup(s, request); try { this.#human(s); const fields = verb === 'library.file' ? ['project', 'path'] : verb === 'library.sync' ? ['project', 'accept'] : ['project']; diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 48e0e61e3..c11d78db9 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -87,6 +87,34 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins } return (name) => levels.get(name) ?? null; } +// Row 224 C2-05b: a host-only adapter. A failed fetch keeps serving immutable old +// objects, as C2-03a requires; no caller may select a repo, commit or sync --accept. +export function createLibraryLookup({ dataRoot, sync, now = () => Date.now() }) { + const methods = { find: libraryModule.find, get: libraryModule.get, search: libraryModule.search, + manuscript: libraryModule.manuscript, universeInfo: libraryModule.universeInfo }; + return async (context, { method, args, authorize }) => { + if (!Object.hasOwn(methods, method)) throw new libraryModule.LibraryError('query-argument-invalid', 'unknown lookup'); + authorize(); + const source = { ...context, dataRoot }; + let state = await libraryModule.libraryStatus(source); + authorize(); + const age = state.lastFetch === null ? Infinity : now() - Date.parse(state.lastFetch); + if (age > libraryModule.SYNC_INTERVAL_MS || age < 0) { + try { await sync(source); } + catch (error) { + // A timer may already be fetching this mirror. Its current served state is still safe. + if (!(error instanceof libraryModule.LibraryError) || error.code !== 'library-sync-busy') throw error; + } + authorize(); + state = await libraryModule.libraryStatus(source); + } + if (!state.served) throw new libraryModule.LibraryError('library-commit', 'library has never been synced'); + authorize(); + return methods[method]({ repo: libraryModule.mirrorPath(source), commit: state.served, + mapping: libraryModule.checkMapping(context.business.projects[context.project]) }, args); + }; +} + export async function startBroker({ dataRoot, businesses, @@ -310,6 +338,8 @@ export async function startBroker({ store, businesses, library: { + lookup: createLibraryLookup({ dataRoot, sync: context => + processing.work(() => libraryModule.sync(context, syncOptions(context.business))) }), readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }), status: context => libraryModule.libraryStatus({ ...context, dataRoot }), sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })), diff --git a/packages/bus/tests/library-lookups.test.mjs b/packages/bus/tests/library-lookups.test.mjs new file mode 100644 index 000000000..d01d0e9ef --- /dev/null +++ b/packages/bus/tests/library-lookups.test.mjs @@ -0,0 +1,226 @@ +// Row 224: exercise broker authorization, real C2-05a queries and host served snapshots. +// All bodies and remotes are made up. External sync is replaced only at its trusted host seam. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Store } from '../src/store.mjs'; +import { Broker } from '../src/broker.mjs'; +import { createLibraryLookup, startBroker } from '../src/runtime.mjs'; +import { serve } from '../src/server.mjs'; +import { Client } from '../src/client.mjs'; +import * as library from '../../library/src/index.mjs'; +import { scratchRepo, note } from '../../library/tests/query-fixture.mjs'; +import { mappingEntry, git } from '../../library/tests/helpers.mjs'; + +const NOW = Date.parse('2026-10-22T16:00:00.000Z'); +const VERBS = ['library.find', 'library.get', 'library.search', 'library.manuscript', 'library.universe']; +const GET = { universe: 'harbor', path: 'Book One/Characters/Tomas.md' }; +const ARGS = [ + { universe: 'harbor', query: 'Tomas' }, GET, + { universe: 'harbor', text: 'ferry' }, { universe: 'harbor', book: 'Book One' }, { universe: 'harbor' }, +]; +const TEXTS = { + 'Book One/Characters/Tomas.md': note('character', 'Tomas', 'He runs the ferry. [[Elsewhere]]'), + 'Book One/Chapters/01.md': '# A ferry\nOne line.\n', + 'Book One/Playground/What If.md': note('character', 'Maybe Tomas', 'Unwritten ferry.'), + 'Second World/Characters/Elsewhere.md': note('character', 'Elsewhere', 'Other ferry.'), +}; + +async function world(t, { projects, adapter, claimed = true, now = () => NOW } = {}) { + const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'data'); + mkdirSync(dataRoot); + const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true }, + projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, + roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } }; + const context = { business, project: 'books', dataRoot }; + let mirror = null, state = null, fetches = 0; + if (business.projects.books?.kind === 'library') { + mirror = await library.ensureMirror(context); + git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served'); + state = { version: 1, branch: 'main', served: f.commit, lastFetch: new Date(NOW).toISOString(), + lastError: null, diverged: null, problems: [] }; + writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify(state), { mode: 0o600 }); + } + const setState = patch => { + state = { ...state, ...patch }; + writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify(state), { mode: 0o600 }); + }; + const lookup = createLibraryLookup({ dataRoot, now, sync: async () => { + fetches++; + setState({ lastFetch: new Date(now()).toISOString() }); + return { state, code: null }; + } }); + const store = new Store(dataRoot); + t.after(() => store.close()); + const broker = new Broker({ store, businesses: { acme: business }, + library: adapter ?? { lookup, LibraryError: library.LibraryError } }); + const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'r224', harness: 'pi' }); + if (claimed) broker.request(agent, { verb: 'role.claim' }); + const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }); + const reader = broker.bindReader({ business: 'acme' }), tester = broker.bindTester({ business: 'acme' }); + const call = (verb, args = {}, cap = agent) => Promise.resolve().then(() => broker.request(cap, { verb, args })); + return { ...f, dataRoot, business, context, mirror, store, broker, agent, human, reader, tester, + call, lookup, setState, fetches: () => fetches }; +} + +test('all five agent verbs read the configured served commit through real queries', async t => { + const f = await world(t); + const [find, get, search, manuscript, universe] = await Promise.all(VERBS.map((verb, i) => f.call(verb, ARGS[i]))); + for (const result of [find, get, search, manuscript, universe]) assert.equal(result.commit, f.commit); + assert.deepEqual(find.results.map(x => x.path), ['Book One/Characters/Tomas.md']); + assert.match(JSON.stringify(get.lines), /He runs the ferry/); + assert.deepEqual(get.links_out, []); // The only target is in another universe. + assert.ok(search.results.length > 0); + assert.ok(search.results.every(x => !x.path.startsWith('Second World/') && x.scope === 'canon')); + assert.equal(manuscript.path, 'Book One/Chapters/01.md'); + assert.deepEqual(universe.books, ['Book One']); + assert.equal(f.fetches(), 0); + const playground = await f.call('library.find', { universe: 'harbor', query: '', scope: 'playground' }); + assert.deepEqual(playground.results.map(x => x.path), ['Book One/Playground/What If.md']); + assert.equal(await f.call('library.get', { universe: 'harbor', path: 'Second World/Characters/Elsewhere.md' }), null); +}); + +test('each verb refuses missing universe and unknown keys before host work', async t => { + let calls = 0; + const f = await world(t, { adapter: { lookup: () => { calls++; } } }); + for (const [i, verb] of VERBS.entries()) { + const { universe, ...missing } = ARGS[i]; + await assert.rejects(f.call(verb, missing), { code: 'invalid-request' }); + for (const extra of [{ repo: '/elsewhere' }, { commit: 'b'.repeat(40) }, { business: 'other' }, { accept: 'b'.repeat(40) }]) + await assert.rejects(f.call(verb, { ...ARGS[i], ...extra }), { code: 'invalid-request' }); + await assert.rejects(f.call(verb, { ...ARGS[i], universe: '' }), { code: 'invalid-request' }); + } + assert.equal(calls, 0); +}); + +test('no library, missing host adapter and undeclared project refuse library-not-configured', async t => { + const noLibrary = await world(t, { projects: {} }); + const noAdapter = await world(t, { adapter: {} }); + const f = await world(t); + for (const [i, verb] of VERBS.entries()) { + await assert.rejects(noLibrary.call(verb, ARGS[i]), { code: 'library-not-configured' }); + await assert.rejects(noAdapter.call(verb, ARGS[i]), { code: 'library-not-configured' }); + await assert.rejects(f.call(verb, { ...ARGS[i], project: 'other' }), { code: 'library-not-configured' }); + } + f.business.projects.second = mappingEntry(); + await assert.rejects(f.call('library.get', GET), { code: 'invalid-request' }); + assert.equal((await f.call('library.get', { ...GET, project: 'books' })).commit, f.commit); +}); + +test('reads need the current holder on each call; human/tester/reader are not agents', async t => { + const f = await world(t, { claimed: false }); + for (const [i, verb] of VERBS.entries()) { + await assert.rejects(f.call(verb, ARGS[i]), { code: 'not-holder' }); + await assert.rejects(f.call(verb, ARGS[i], f.human), { code: 'agent-required' }); + await assert.rejects(f.call(verb, ARGS[i], f.tester), { code: 'tester-refused' }); + await assert.rejects(f.call(verb, ARGS[i], f.reader), { code: 'read-only' }); + } + f.broker.request(f.agent, { verb: 'role.claim' }); + assert.equal((await f.call('library.get', GET)).commit, f.commit); + f.broker.request(f.agent, { verb: 'role.release' }); + await assert.rejects(f.call('library.get', GET), { code: 'not-holder' }); +}); + +test('releasing a claim during an async lookup fences the response', { timeout: 30_000 }, async t => { + let release, entered; + const started = new Promise(resolve => { entered = resolve; }); + const f = await world(t, { adapter: { lookup: async () => { + entered(); await new Promise(resolve => { release = resolve; }); return { lines: [[1, 'not returned']] }; + } } }); + const pending = f.call('library.get', GET); + await started; + f.broker.request(f.agent, { verb: 'role.release' }); + release(); + await assert.rejects(pending, { code: 'not-holder' }); +}); + +test('a business library removed during an async read cannot leak its result', { timeout: 30_000 }, async t => { + let release, entered; + const started = new Promise(resolve => { entered = resolve; }); + const f = await world(t, { adapter: { lookup: async () => { + entered(); await new Promise(resolve => { release = resolve; }); return { lines: [[1, 'not returned']] }; + } } }); + const pending = f.call('library.get', GET); + await started; + delete f.business.projects.books; + release(); + await assert.rejects(pending, { code: 'library-not-configured' }); +}); + +test('freshness boundary, unknown last fetch and failed fetch still use immutable served state', async t => { + const f = await world(t); + f.setState({ lastFetch: new Date(NOW - 60_000).toISOString() }); + await f.call('library.get', GET); + assert.equal(f.fetches(), 0); + f.setState({ lastFetch: new Date(NOW - 60_001).toISOString() }); + await f.call('library.get', GET); + assert.equal(f.fetches(), 1); + f.setState({ lastFetch: null }); + await f.call('library.universe', { universe: 'harbor' }); + assert.equal(f.fetches(), 2); + let attempted = 0; + const failing = createLibraryLookup({ dataRoot: f.dataRoot, now: () => NOW, sync: async () => { + attempted++; + f.setState({ lastError: { code: 'library-fetch-failed', at: new Date(NOW).toISOString() } }); + return { code: 'library-fetch-failed' }; + } }); + f.setState({ lastFetch: null }); + const result = await failing(f.context, { method: 'get', args: GET, authorize() {} }); + assert.equal(attempted, 1); + assert.equal(result.commit, f.commit); +}); + +test('an unreadable sync state fails closed; a busy sync keeps serving the existing pin', async t => { + const f = await world(t); + const lookup = createLibraryLookup({ dataRoot: f.dataRoot, now: () => NOW, sync: async () => { + throw new library.LibraryError('library-sync-busy', 'another fetch'); + } }); + f.setState({ lastFetch: null }); + assert.equal((await lookup(f.context, { method: 'get', args: GET, authorize() {} })).commit, f.commit); + writeFileSync(join(f.mirror, library.SYNC_STATE_FILE), '{bad json', { mode: 0o600 }); + await assert.rejects(f.call('library.get', GET), { code: 'library-state-invalid' }); +}); + +test('a stale lookup sees the commit published by its sync, not the prior served commit', async t => { + const f = await world(t); + writeFileSync(join(f.repo, GET.path), note('character', 'Tomas', 'Changed ferry.')); + git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'second fixture'); + const next = git(f.repo, 'rev-parse', 'HEAD'); + const lookup = createLibraryLookup({ dataRoot: f.dataRoot, now: () => NOW, sync: async () => { + git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', f.mirror, 'HEAD:refs/heads/served'); + f.setState({ served: next, lastFetch: new Date(NOW).toISOString() }); + return { code: null }; + } }); + f.setState({ lastFetch: null }); + const result = await lookup(f.context, { method: 'get', args: GET, authorize() {} }); + assert.equal(result.commit, next); + assert.match(JSON.stringify(result.lines), /Changed ferry/); +}); + +test('real socket awaits lookup results and rechecks rejected query arguments', { timeout: 30_000 }, async t => { + const f = await world(t); + const path = join(f.root, 'lookup.sock'); + const server = await serve({ broker: f.broker, path }); t.after(() => server.close()); + const client = new Client({ path, cap: f.agent }); + const result = await client.call('library.get', GET); + assert.equal(result.commit, f.commit); + await assert.rejects(client.call('library.find', { universe: 'harbor', query: '', scope: 'anything' }), /query-argument-invalid/); +}); + +test('runtime actually injects the lookup adapter, without exposing human library.file', { timeout: 30_000 }, async t => { + const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'runtime'); mkdirSync(dataRoot); + const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, + arbiters: { technical: 'coder', delivery: 'coder' }, + roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } }; + const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); t.after(() => runtime.close()); + const mirror = library.mirrorPath({ business, project: 'books', dataRoot }); + git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served'); + writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify({ version: 1, branch: 'main', served: f.commit, + lastFetch: new Date().toISOString(), lastError: null, diverged: null, problems: [] }), { mode: 0o600 }); + const cap = runtime.broker.bindLaunch({ business: 'acme', role: 'coder', run: 'r224-runtime', harness: 'pi' }); + runtime.broker.request(cap, { verb: 'role.claim' }); + assert.equal((await runtime.broker.request(cap, { verb: 'library.get', args: GET })).commit, f.commit); + await assert.rejects(runtime.broker.request(cap, { verb: 'library.file', args: { project: 'books', path: GET.path } }), + { code: 'human-required' }); +}); diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index b59add550..c59203c0d 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -528,6 +528,7 @@ export function createLauncher({ contract: readFileSync(resolved.contract, "utf8"), arbiter: Object.values(b.arbiters).includes(instance), tracker, + library: Object.values(b.projects ?? {}).some(project => project.kind === "library"), credentials, session: { run, launchedBy, workspace: inSandbox, toolSocket, turnMarker, checkouts: inSandbox === workspace ? checkouts : checkouts.map((c) => ({ ...c, path: join(inSandbox, relative(workspace, c.path)) })) }, harness, diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs index 3e313e49d..1a28f6ace 100644 --- a/packages/cli/tests/thread-launch.test.mjs +++ b/packages/cli/tests/thread-launch.test.mjs @@ -27,6 +27,7 @@ import { bootConfig, loadSystem } from "../src/config.mjs"; import { startTimeOf } from "../src/host.mjs"; import { createLauncher } from "../src/launcher.mjs"; import { fakeHarnesses, fixture, tmp } from "./helpers.mjs"; +import { mappingEntry } from "../../library/tests/helpers.mjs"; // Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a // legacy thread, with the thread's id. const DEFAULT_SESSION = [ @@ -533,3 +534,24 @@ test('C2-09a: terminating one creative thread does not signal an unselected peer await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0])); assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1])); }); + +// Row224: the launcher must derive library presence from business projects, not +// from session checkouts (a library never has an agent clone). +test("C2-05b a real launched bundle gets lookups for a library project without a library checkout", { skip, timeout: 60000 }, async t => { + const prepared = prepare(t, { idle: 10, edit: doc => { + doc.projects.books = mappingEntry(); + return doc; + } }); + const { f } = prepared; + const host = await start(t, prepared); + const project = host.call("project.create", { name: "library work", repo: "stack" }).id; + const thread = host.call("thread.create", { project, name: "lookup" }).id; + await ask(host.call, thread, "inspect bundle"); + const run = launchLog(f).find(e => e.event === "launch" && e.thread === thread).run; + const tools = JSON.parse(readFileSync(join(f.dataRoot, "launches", "acme", run, "bundle", "tools.json"), "utf8")); + assert.deepEqual(tools.filter(tool => tool.name.startsWith("library_")).map(tool => tool.name), + ["library_find", "library_get", "library_search", "library_manuscript", "library_universe"]); + const [session] = threadRuns(f, thread); + assert.ok(session); + assert.ok(!session.checkouts?.some(checkout => checkout.project === "books")); +}); diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs index 2fb792d5d..ab0c72fa7 100644 --- a/packages/harness/src/bundle.mjs +++ b/packages/harness/src/bundle.mjs @@ -292,6 +292,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona // resolved resolveInstance(...) output // contract the role contract's text // arbiter true when the business names this instance as an arbiter +// library true when the trusted business configuration has a library project // tracker true when the broker has task verbs for the business // credentials the broker's credentialStatus(...) for the instance (metadata only) // session { run, launchedBy, workspace, toolSocket, turnMarker, checkouts } @@ -309,11 +310,11 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona // (row 213); an instance with persona.sha256 and no personaDir refuses // Returns { manifest, files, policy, tools, env } with absolute paths; env is the module's // adapter variables, which the host writes into session.json. -export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null, personaDir = null }) { +export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, library = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null, personaDir = null }) { if (route !== "primary" && route !== "fallback") throw new BundleError(`route ${JSON.stringify(route)} isn't primary or fallback`); const model = sessionModel(resolved, models, route); const web = webPlan(resolved, model.harness, harness.builtinWeb()); - const tools = typedTools({ resolved, arbiter, tracker, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land }); + const tools = typedTools({ resolved, arbiter, tracker, library, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land }); const untyped = untypedActions(resolved, tools); const persona = readPersona(resolved, personaDir); mkdirSync(dir, { recursive: true, mode: 0o700 }); diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index 1c038a366..8bee29e1d 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -78,9 +78,10 @@ const TASK_TOOLS = { const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESULT", "RESULT", "INFO", "DECISION", "REACTION", "BLOCK"]; -// typedTools({ resolved, arbiter, tracker, projects, land }) -> [{ name, verb, action, description, parameters }] +// typedTools({ resolved, arbiter, tracker, projects, land, library }) -> [{ name, verb, action, description, parameters }] // resolved resolveInstance(...) output for the session's instance // arbiter true when the business names this instance as an arbiter +// library true when the trusted business configuration has a library project // tracker true when the broker has task verbs for the business // projects the project ids the session has a clone of // land true when the role and thread hold a merge decision a landing could use (landHeld) @@ -88,7 +89,7 @@ const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESU // `verb` is the broker verb; the `launch` tool has verb "launch" and goes // to the host instead, and "working.push", "working.fetch" and // "working.land" go to the host's git socket. -export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false }) { +export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false, library = false }) { const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]); const tools = []; const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters }); @@ -222,6 +223,32 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project url: str("The https url to fetch", { maxLength: 2048 }), }, ["url"])); } + // Row224: no library clone or token reaches a session. The broker chooses the served + // commit and requires a universe on every call; cursors page without cutting replies. + if (library) { + const universe = str("Universe ID in the configured library", { minLength: 1 }); + const project = str("Library project ID; omit only when the business has one library"); + const scope = str("canon by default; playground is opt-in", { enum: ["canon", "playground"] }); + const book = str("Book folder from library_universe"); + const offset = { type: "integer", minimum: 0, description: "next from the previous page" }; + const line = { type: "integer", minimum: 1, description: "One-based line number" }; + const base = { universe, project }; + add("library_find", "library.find", null, "Find names in one universe, at the served commit. At most 50 results; continue with next as offset.", obj({ + ...base, query: str("Case-insensitive name or filename substring; empty matches all"), + type: str("Mapped type", { enum: ["character", "place", "research", "tracker", "summary"] }), + book, scope, offset, + }, ["universe", "query"])); + add("library_get", "library.get", null, "Read a mapped note and visible links in one universe at the served commit. next pages lines; links_next pages links. An explicit own-universe playground path is readable.", obj({ + ...base, path: str("Mapped note path relative to the library"), from: line, links_from: offset, + }, ["universe", "path"])); + add("library_search", "library.search", null, "Search text in one universe at the served commit. At most 50 hits; use next as offset. Playground is opt-in.", obj({ + ...base, text: str("Nonempty case-insensitive substring", { minLength: 1 }), book, scope, offset, + }, ["universe", "text"])); + add("library_manuscript", "library.manuscript", null, "Read one chapter of a book at the served commit, at most 400 lines. Omit chapter for the first; continue with next or next_chapter.", obj({ + ...base, book, scope, chapter: str("Chapter filename without extension"), from: line, to: line, + }, ["universe", "book"])); + add("library_universe", "library.universe", null, "Read a universe's mapping at the served commit: books, types, playground prefixes, and hybrid sources/constraints/notes or draft metadata.", obj(base, ["universe"])); + } add("read_spec", "spec.read", null, "Read a project's committed north-star spec and its file history. commit is an optional full reachable commit ID. The file commit, rather than HEAD, is what a decision cites. confirmed says the file commit was authored by the business's human.", obj({ project: str("Project ID from the business file"), commit: str("Optional full historical commit ID", { pattern: "^[0-9a-f]{40}$" }), @@ -277,6 +304,11 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project if (held.has("task.create")) add("list_requirements", "requirements", null, "List the approved PRD's requirement ids, each with a one-line title. task_create takes only these. When none fits a request, raise a prd.approve decision instead of creating the task.", obj({})); } + // mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The + // C2-08 web grant still supplies broker web reads to companion and research only. + if (CREATIVE_DEFINITIONS.includes(resolved.definition)) + return tools.filter(tool => tool.name === "send_message" || tool.name.startsWith("library_") || + tool.name === "web_search" || tool.name === "web_fetch"); return tools; } diff --git a/packages/harness/tests/library-tools.test.mjs b/packages/harness/tests/library-tools.test.mjs new file mode 100644 index 000000000..771b53956 --- /dev/null +++ b/packages/harness/tests/library-tools.test.mjs @@ -0,0 +1,69 @@ +// Row224 M3: exact creative tool sets preserve the existing C2-08 web grants. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { typedTools } from '../src/tools.mjs'; +import { buildBundle } from '../src/bundle.mjs'; +import { HARNESS, REPO_SKILLS, resolvedFor, scratch } from './helpers.mjs'; + +const LOOKUPS = ['library_find', 'library_get', 'library_search', 'library_manuscript', 'library_universe']; +const CREATIVE = ['companion', 'research', 'ideas', 'proofreader', 'fact-checker', 'continuity']; +const role = (t, definition, harness = 'pi') => resolvedFor(t, 'bean', d => { + d.roles.bean = { definition, vars: { harness, model: 'claude-sonnet-5-5', thinking: 'high' } }; +}); +const expected = definition => ['send_message', ...(['companion', 'research'].includes(definition) + ? ['web_search', 'web_fetch'] : []), ...LOOKUPS]; + +test('M3: per-role snapshots contain six names, or eight with the C2-08 web grant', t => { + for (const definition of CREATIVE) { + const tools = typedTools({ resolved: role(t, definition), library: true, tracker: true, arbiter: true }); + assert.deepEqual(tools.map(t => t.name), expected(definition), definition); + assert.ok(tools.every(t => t.parameters.additionalProperties === false)); + } +}); + +test('every noncreative role gets lookups when a library is configured, not otherwise', t => { + for (const instance of ['coder', 'reviewer', 'pm', 'chief']) { + const resolved = resolvedFor(t, instance); + const ordinary = typedTools({ resolved }).map(t => t.name); + assert.ok(!ordinary.some(name => name.startsWith('library_'))); + const tools = typedTools({ resolved, library: true }); + assert.deepEqual(tools.filter(t => t.name.startsWith('library_')).map(t => t.name), LOOKUPS); + assert.deepEqual(tools.filter(t => !t.name.startsWith('library_')).map(t => t.name), ordinary); + } +}); + +test('lookup schemas require a universe even for get, include paging, and cannot select repo or commit', t => { + const tools = typedTools({ resolved: role(t, 'ideas'), library: true }); + const required = [ ['universe', 'query'], ['universe', 'path'], ['universe', 'text'], ['universe', 'book'], ['universe'] ]; + for (const [i, tool] of tools.filter(t => t.name.startsWith('library_')).entries()) { + assert.deepEqual(tool.parameters.required, required[i]); + assert.equal(tool.verb, LOOKUPS[i].replace('_', '.')); + assert.equal(tool.action, null); + assert.equal(tool.parameters.additionalProperties, false); + for (const key of ['repo', 'commit', 'business', 'accept']) assert.equal(Object.hasOwn(tool.parameters.properties, key), false); + } + const byName = Object.fromEntries(tools.map(t => [t.name, t])); + assert.deepEqual(byName.library_find.parameters.properties.scope.enum, ['canon', 'playground']); + assert.equal(byName.library_find.parameters.properties.offset.minimum, 0); + assert.equal(byName.library_get.parameters.properties.from.minimum, 1); + assert.equal(byName.library_get.parameters.properties.links_from.minimum, 0); + assert.equal(byName.library_manuscript.parameters.properties.chapter.type, 'string'); +}); + +test('bundle flag reaches both harness tool manifests and creative prompt without generic tools', t => { + for (const definition of CREATIVE) for (const harness of ['pi', 'claude-code']) { + const dir = scratch(t), workspace = join(dir, 'ws'); mkdirSync(workspace); + const b = buildBundle({ dir: join(dir, 'bundle'), resolved: role(t, definition, harness), library: true, + contract: '# Creative\n', tracker: true, arbiter: true, skillsDir: REPO_SKILLS, + session: { run: 'r224', launchedBy: 'chief', workspace, toolSocket: join(dir, 't.sock'), turnMarker: join(dir, 'turn.done') }, + harness: HARNESS[harness] }); + assert.deepEqual(b.tools.map(t => t.name), expected(definition), `${definition} ${harness}`); + const serialized = JSON.parse(readFileSync(b.files.tools, 'utf8')); + assert.deepEqual(serialized.map(t => t.name), expected(definition)); + const prompt = readFileSync(b.files.prompt, 'utf8'); + assert.match(prompt, /library_find/); + assert.doesNotMatch(prompt, /Bus tools: [^\n]*(read_spec|list_agents|inbox|trail|write_record|raise_decision|list_tasks)/); + } +});