feat: carry reviewed C2-04 library entry onto row 197 landing

This commit is contained in:
Herb committed 2026-10-21 13:47:57 -05:00
1 parent a365237133
commit a6101e21c5
30 files changed
+919 -15

No files matched your search

+2
View File
@@ -5177,3 +5177,5 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
2026-10-21T01:28:05Z | Darkwing | row 232 #1734 installer I1 build, round 1 in review | Before: stack-release.sh managed only user units (systemctl --user), and stack-copy.sh had no bundle copy. After: --units external (adopt, activate, rollback --to, status, confirm, revert, gate --since, AWAITING-UNITS, stale-start, external-pm, --plan --expect-from) and stack-copy check --units external, copy --bundle FILE SHA, wrapper ID --create; user mode byte-identical to the base by test. First build 0af0c73c6 on 00d4cc1f6; rebased onto row 200 (cb5fdb064) as a0ffabc3f on darkwing/row232-r1; --sso refused under external (D9), row 200 config_checks run under external (D10). Gate on Sage's slot (sage-1618): cli 561/0, every scripts/test-*.sh green; 17/17 mutants killed. Results commit 84f1a91e6 (pushed, seat branch only). Manifest 7a3b4ea1 (9 paths), queue revs 2595-2596, request comment 29825, reviewer Filbert. Packet agents/darkwing/work/queue-232/BUILD.md.
2026-10-21T04:50:44Z | Darkwing | row 233 #1735 installer I3 build, round 1 in review | Before: root had no upgrade driver for root-owned units and no host run sheet; stack-release.sh had no rehearse --backup last and no external plan refusals. After: scripts/stack-upgrade.sh upgrade and rollback (r6 A1-A5, A2f, R1-R4) with 20 tests, docs/guides/host-run-sheet.md, TOOLS.md lines, and on Sage's rulings stack-release.sh --backup last, BACKUP_STORE and the --plan refusals open-activation and migrate-required (3 tests). Base e65a3ab4d (row 232's landing). Gate on Sage's slot (sage-1704) on d99166ade: cli 616/0, every scripts/test-*.sh green, 1201/0 summed by awk; m1-m8 8/8 killed. Correction: the process sweep after the gate found an orphaned sleep 60 from the lock test's flock holder (N1); 08db6ac3f fixes it test only, Sage ruled the gate stands (sage-1707), rerun c0 20/0 and m1 killed with 0 left (sage-1708). Tip 1a1dc80ca (pushed, seat branch only). Manifest 464ce9eb (7 paths), queue revs 2644 and 2647, request comment 29901, reviewer Filbert. Packet agents/darkwing/work/queue-233/BUILD.md.
2026-10-21T06:37:41Z | Darkwing | row 240 #1746 installer I4 build, round 1 in review | Before: root had no host check for the bwrap AppArmor profile, its pins or a boot unit, and stack-upgrade.sh exited 1 args-failed when the args move after A5 failed, while exit 1 means the old copy runs. After: scripts/case1-hostcheck.sh (seven items, --check, --enforce, --upstream), scripts/case1-pins.sh, mosaic-hostcheck.service and 16 tests; the args move failure exits 5 args-failed, documented in TOOLS.md and run sheet section 6 and tested; run sheet section 3 puts the pinned npm on PATH and names the bin/claude.exe --claude path; sections 8 and 10 have precheck lines. Verification: slot run r1 on 47ec9fe2f failed at the singles (the new N1 test's after hook threw ENOENT after the fixture cleanup removed the root); b45852daa drops the hook; r2 on b45852daa green: singles 16/0 and 21/0, c0 plus M1-M8 8/8 killed, cli 639/0, scripts 585/0. Candidate darkwing/row240-r3 ad1b23ab2 on c771a36f4, manifest 573ece95 (9 paths), revs 2694 and 2695, comment 29943.
2026-10-21T09:00Z | Herb | row 215 C2-04 implementation started | Before: business files reject library entries and root consumers assume code projects; the organization writer can change all project mappings. Plan: approved manifest 2522025af on C2-01 a2c72ac95, Filbert reply to herb-0056, scope sage-1848/1852/1860. Focused slot sage-1869 observed expected red mapping and M9 cases, then 14/0 green for new T1/T2 cases, no skips; clear herb-0062 with empty test/fixture process scan. After that clear, T3/T4 tests and host-only mirror/bus implementation continue under sage-1874, unrun while Sage holds the slot. Full singles, suites and implementation review remain pending. No source commit, push or live change.
2026-10-21T13:29Z | Herb | row 215 C2-04 source verified for candidate review | Before: business code assumed every project had a root and organization writes could replace project mappings. After: library mappings load without agent checkouts or publish grants, project mappings are protected before and after serialization, host-only bare mirrors share a bounded publication lock with sync, and library.file/library.status are human-only async bus reads. Base f8276c5eb preserves all nine landed overlaps. Granted sage-2008 whole singles: 16 files, 451/0, no skips or cancellations, ended 13:27:27.279728Z; all source hashes unchanged, process scan empty, no own-path drift through 8a04f4aa4. Full landing gate and implementation review remain pending; no HTTP, sync writer or live change.
+26 -1
View File
@@ -88,6 +88,7 @@ export const TASK_PAGE_MAX = 200;
// Row 148 (#1642): the human-only verbs behind the Console's document editor. The save is the one
// request allowed past the ordinary 64 KiB envelope (server.mjs and client.mjs), up to its content cap.
export const PROJECT_FILE_VERBS = Object.freeze(['project.files', 'project.file', 'project.file.save']);
export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status']);
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']);
@@ -279,6 +280,7 @@ export class Broker {
#organization;
#credentialVerbs;
#project;
#library;
#usage;
#limits;
#harnessOn;
@@ -294,7 +296,7 @@ export class Broker {
#stops = new Map();
#clock = 0;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
this.#businesses = structuredClone(businesses);
this.#store = store;
@@ -306,6 +308,7 @@ export class Broker {
this.#hostGrant = hostGrant;
this.#hostKeys = hostKeys;
this.#project = project;
this.#library = library;
this.#usage = usage;
this.#limits = limits;
// Row 65: harnessOn(business, name) is true when the organization file's plugins.harness switches
@@ -2192,6 +2195,8 @@ export class Broker {
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
if (PROJECT_FILE_VERBS.includes(request.verb))
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
if (LIBRARY_VERBS.includes(request.verb))
return this.#requestLibrary(s, { verb: request.verb, args: structuredClone(args) });
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
@@ -2441,6 +2446,26 @@ export class Broker {
throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request);
}
}
// C2-04 M4: only a verified human reads library text or status. Host Git
// functions stay injected, preserving this module's sandbox import closure.
async #requestLibrary(s, request) {
const { verb, args } = request;
try {
this.#human(s);
const fields = verb === 'library.file' ? ['project', 'path'] : ['project'];
keys(args, fields, fields);
if (!this.#library?.readFile || !this.#library?.status) fail('project-not-configured');
const context = { business: this.#business(s.business), project: args.project };
const result = verb === 'library.file'
? await this.#library.readFile({ ...context, path: args.path })
: await this.#library.status(context);
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, request);
}
}
// The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the
// cap to authorize and record through authorize() and recordTask(); refusals are recorded as in request().
async requestTask(cap, request, handler) {
+12 -1
View File
@@ -3,6 +3,7 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from './store.mjs';
import * as projectModule from '../../project/src/index.mjs';
import * as libraryModule from '../../library/src/index.mjs';
import { Broker, BusError } from './broker.mjs';
import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
@@ -92,7 +93,7 @@ export async function startBroker({
const protectedRoots = [
fileURLToPath(new URL('../../../', import.meta.url)),
...repoRoots,
...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).map((p) => p.root)),
...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).filter(p => p.kind !== 'library').map((p) => p.root)),
];
// Row 199 (#1697, design D1): the per-agent store is <configDir>/secrets/agents.
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
@@ -128,6 +129,11 @@ export async function startBroker({
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
// Row 210 (#1707, design D7 step 6) and row 234 (#1740, r6 E2): the run dir and the host enrollment half.
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
// C2-04: mirrors are host state, never session checkouts. An unsafe
// existing mirror refuses startup before a socket can become ready.
for (const business of Object.values(businesses))
for (const [project, entry] of Object.entries(business.projects ?? {}))
if (entry.kind === 'library') await libraryModule.ensureMirror({ business, project, dataRoot });
const broker = new Broker({
hostGrant: runRoot ? hostGrant({ configDir: organization.dir, runRoot }) : null,
// Row 235 (#1741, r6 X4, X5): the grant executor's claim, register and issue, never use().
@@ -136,6 +142,11 @@ export async function startBroker({
limits,
store,
businesses,
library: {
readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }),
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
LibraryError: libraryModule.LibraryError,
},
project: {
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
writeRecord: context => projectModule.writeRecord({ ...context, dataRoot }),
+2 -2
View File
@@ -1,6 +1,6 @@
import { createServer } from 'node:net';
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, PROJECT_FILE_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
const LIMIT = 65536;
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
@@ -122,7 +122,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
}
// Row138: only project Git effects need an await; preserve every other sync path below.
if (r.verb === 'usage.begin' || r.verb === 'usage.end' ||
r.verb === 'spec.read' || r.verb === 'record.write' || PROJECT_FILE_VERBS.includes(r.verb) ||
r.verb === 'spec.read' || r.verb === 'record.write' || PROJECT_FILE_VERBS.includes(r.verb) || LIBRARY_VERBS.includes(r.verb) ||
// Row 234 (#1740): a grant raise awaits its keyscan, and hosts.check its ssh.
HOSTS_VERBS.includes(r.verb) ||
(r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit') ||
+123
View File
@@ -0,0 +1,123 @@
// C2-04 M4 and real async transport, with made-up local library data.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { startBroker } from '../src/runtime.mjs';
import { mappingEntry, repo, git, BASE, MARKER } from '../../library/tests/helpers.mjs';
import * as library from '../../library/src/index.mjs';
function world(t, adapter = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-library-')), store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
const broker = new Broker({ store, businesses: { acme: business }, library: adapter });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-agent', harness: 'pi' });
const reader = broker.bindReader({ business: 'acme' }), tester = broker.bindTester({ business: 'acme' });
const call = (cap, verb, args = {}) => Promise.resolve().then(() => broker.request(cap, { verb, args }));
return { root, store, broker, business, human, agent, reader, tester, call };
}
test('C2-04 M4: both verbs reject every agent before arguments or adapter access', async t => {
let touched = 0;
const f = world(t, { readFile: () => { touched++; }, status: () => { touched++; } });
for (const verb of ['library.file', 'library.status']) {
await assert.rejects(f.call(f.agent, verb, { unknown: true }), { code: 'human-required' });
await assert.rejects(f.call(f.tester, verb, { unknown: true }), { code: 'human-required' });
await assert.rejects(f.call(f.reader, verb, { unknown: true }), { code: 'read-only' });
}
assert.equal(touched, 0);
const refusals = f.store.all("SELECT body FROM events WHERE kind='action.refused'").map(row => JSON.parse(row.body));
assert.equal(refusals.length, 6);
assert.equal(JSON.stringify(refusals).includes('unknown'), false);
const absent = world(t, null);
for (const verb of ['library.file', 'library.status'])
await assert.rejects(absent.call(absent.agent, verb, {}), { code: 'human-required' });
});
test('C2-04 bus: human reads validate keys and return adapter results without path leakage', async t => {
const seen = [];
const f = world(t, { readFile: async args => { seen.push(args); return { project: 'books', path: 'Book One/A.md', commit: 'a'.repeat(40), content: '# Made up\n' }; },
status: async args => { seen.push(args); return { project: 'books', served: null, problems: [] }; }, LibraryError: library.LibraryError });
assert.equal((await f.call(f.human, 'library.file', { project: 'books', path: 'Book One/A.md' })).content, '# Made up\n');
assert.equal((await f.call(f.human, 'library.status', { project: 'books' })).served, null);
assert.equal(seen[0].business.id, 'acme'); assert.equal(seen[1].project, 'books');
for (const [verb, args] of [['library.file', { project: 'books' }], ['library.file', { project: 'books', path: 'Book One/A.md', commit: 'a'.repeat(40) }], ['library.status', { project: 'books', extra: 1 }]])
await assert.rejects(f.call(f.human, verb, args), { code: 'invalid-request' });
});
test('C2-04 socket: each async library verb resolves its result rather than serializing {}', async t => {
const f = world(t, { readFile: async () => ({ project: 'books', path: 'Book One/A.md', commit: 'a'.repeat(40), content: '# Fixture\n' }),
status: async () => ({ project: 'books', served: null, problems: [] }), LibraryError: library.LibraryError });
const path = join(f.root, 'bus.sock');
const server = await serve({ broker: f.broker, path, authenticateHuman: () => f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }) });
t.after(() => server.close());
const human = new Client({ path, human: { fixture: true } });
assert.deepEqual(await human.call('library.status', { project: 'books' }), { project: 'books', served: null, problems: [] });
assert.equal((await human.call('library.file', { project: 'books', path: 'Book One/A.md' })).content, '# Fixture\n');
});
test('C2-04 runtime: protectedRoots skips library but retains real code-root credential protection', async t => {
const f = repo(t), dataRoot = join(f.root, 'data'), token = join(f.root, 'fixture-token');
mkdirSync(dataRoot, { mode: 0o700 }); writeFileSync(token, 'made-up-fixture-token-123456', { mode: 0o600 });
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry(), stack: { root: f.repo } },
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } };
const runtime = await startBroker({ dataRoot, businesses: { acme: business } });
await runtime.close();
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
assert.equal(git(mirror, 'rev-parse', '--is-bare-repository'), 'true');
const bad = join(f.repo, 'credential'); writeFileSync(bad, 'made-up-fixture-token-123456', { mode: 0o600 });
business.roles.coder.credentials.gitea.file = bad;
await assert.rejects(startBroker({ dataRoot, businesses: { acme: business } }));
});
test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a socket', async t => {
const f = repo(t), dataRoot = join(f.root, 'data');
mkdirSync(dataRoot, { mode: 0o700 });
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
const mirror = join(dataRoot, 'projects/acme/books'); mkdirSync(mirror, { recursive: true });
writeFileSync(join(mirror, 'keep'), 'made-up note');
await assert.rejects(startBroker({ dataRoot, businesses: { acme: business } }), { code: 'library-git' });
assert.equal(existsSync(join(dataRoot, 'bus.sock')), false);
assert.equal(readFileSync(join(mirror, 'keep'), 'utf8'), 'made-up note');
});
test('C2-04 runtime: real library adapters reach the socket and lock waits leave other bus reads responsive', { timeout: 15000 }, async t => {
const f = repo(t), dataRoot = join(f.root, 'data');
mkdirSync(dataRoot, { mode: 0o700 });
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
const runtime = await startBroker({ dataRoot, businesses: { acme: business } });
t.after(() => runtime.close());
const cap = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const path = join(f.root, 'human-fixture.sock');
const server = await serve({ broker: runtime.broker, path, authenticateHuman: () => runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }) }); t.after(() => server.close());
const human = new Client({ path, human: { fixture: true } });
assert.equal((await human.call('library.status', { project: 'books' })).served, null);
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: null, lastError: null, diverged: null, problems: [] }), { mode: 0o600 });
const file = await human.call('library.file', { project: 'books', path: 'Book One/Characters/Tomas.md' });
assert.equal(file.content, BASE['Book One/Characters/Tomas.md']);
assert.equal(file.commit, f.commit);
let release, done = false;
const hold = library.withLibraryLock(mirror, () => new Promise(r => { release = r; }));
const deadline = Date.now() + 5000;
while (!release && Date.now() < deadline) await new Promise(r => setTimeout(r, 5));
assert.equal(typeof release, 'function');
t.after(async () => { release(); await hold; });
const pending = human.call('library.status', { project: 'books' }).then(result => { done = true; return result; });
const ordinary = await Promise.race([human.call('agents'), new Promise((_, reject) => {
const timer = setTimeout(() => reject(Error('ordinary bus read blocked by lock acquisition')), 1000); timer.unref();
})]);
assert.ok(ordinary && typeof ordinary === 'object'); assert.equal(done, false);
release(); await hold;
assert.equal((await pending).served, f.commit);
});
+6
View File
@@ -10,6 +10,7 @@ import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { checkPlugins } from "../../plugins/src/kinds.mjs";
import { checkMapping } from "../../library/src/mapping.mjs";
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
@@ -239,6 +240,10 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
for (const [id, entry] of Object.entries(document.projects)) {
requireId(id, `${file} project id`);
requireObject(entry, `${file} projects.${id}`);
if (entry.kind === 'library') {
projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) });
continue;
}
rejectUnknownKeys(entry, ["root", "repo", "remote"], `${file} projects.${id}`);
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
@@ -255,6 +260,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
for (const [instance, role] of Object.entries(roles)) {
for (const project of role.publish ?? []) {
if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`);
if (projects[project].kind === 'library') refuse(`${file} roles.${instance}.publish names library project ${project}; library repositories cannot be published by agents`);
if (!projects[project].remote) refuse(`${file} roles.${instance}.publish names ${project}, which has no "remote"`);
const problem = forkProblem(projects[project].remote, role.identity);
if (problem) refuse(`${file} roles.${instance}.publish names ${project}: ${problem}`);
+3 -2
View File
@@ -58,7 +58,7 @@ function checkRefs(refs, forbiddenRoots) {
}
function forbiddenRoots(config, business) {
return [REPO, config.dataRoot, ...Object.values(business.projects).map((p) => p.root)];
return [REPO, config.dataRoot, ...Object.values(business.projects).filter(p => p.kind !== 'library').map((p) => p.root)];
}
function validate(args) {
@@ -69,6 +69,7 @@ function validate(args) {
const projects = {};
const warnings = [];
for (const [id, entry] of Object.entries(business.projects)) {
if (entry.kind === 'library') { projects[id] = 'library'; continue; }
const file = projectFilePath(entry.root);
if (!existsSync(file)) {
projects[id] = "absent";
@@ -115,7 +116,7 @@ function resolveCommand(args) {
if (project !== null) {
const entry = Object.hasOwn(business.projects, project) ? business.projects[project] : null;
if (!entry) fail(2, `business ${business.id} declares no project ${JSON.stringify(project)}`);
loaded = loadProject(entry.root);
if (entry.kind !== 'library') loaded = loadProject(entry.root);
}
const resolved = resolveInstance({ system: systemVars(config), business, project: loaded, instance });
const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, business));
@@ -147,6 +147,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
}
checkRoutes({ proposed, old: before, levels });
for (const [project, entry] of Object.entries(proposed.projects)) {
if (entry.kind === 'library') continue;
if (!existsSync(projectFilePath(entry.root))) continue;
const loaded = loadProject(entry.root);
if (loaded.id !== project) throw new OrganizationError("organization-project-mismatch");
+7 -1
View File
@@ -185,12 +185,18 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const oldBytes = regular(file);
const before = sha256(oldBytes);
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
validateBusinessDocument(document, file, { rolesDir });
const old = JSON.parse(oldBytes);
// C2-04 M9: every organization setter preserves the whole project
// mapping, including library playgrounds and hybrid universes.
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
const written = JSON.parse(bytes);
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
validateBusinessDocument(written, file, { rolesDir });
const after = sha256(bytes);
const transaction = randomUUID();
+1 -1
View File
@@ -41,7 +41,7 @@ export function resolveInstance({ system, business, project = null, instance })
];
let projectId = null;
if (project) {
const declared = Object.entries(business.projects).find(([, p]) => projectFilePath(p.root) === project.file);
const declared = Object.entries(business.projects).find(([, p]) => p.kind !== 'library' && projectFilePath(p.root) === project.file);
if (!declared || declared[0] !== project.id) {
refuse(`project ${project.id} (${project.file}) isn't declared under that id in business ${business.id}`);
}
+30
View File
@@ -8,6 +8,36 @@ import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusin
import { THREAD_IDLE_SECONDS } from "../src/business.mjs";
import { launchDoc, businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
test('C2-04: library mappings load without roots, remain frozen, and retain mapping refusals', async t => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
const { rmSync } = await import('node:fs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root), file = join(root, 'businesses/acme.json');
doc.projects = { books: mappingEntry(), ...doc.projects };
const loaded = validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
const { checkMapping } = await import('../../library/src/mapping.mjs');
assert.deepEqual(loaded.projects.books, checkMapping(mappingEntry()));
assert.equal(loaded.projects.books.root, undefined);
assert.ok(Object.isFrozen(loaded.projects.books.universes.harbor.books));
const only = structuredClone(doc); delete only.projects.stack;
assert.deepEqual(Object.keys(validateBusinessDocument(only, file, { rolesDir: REPO_ROLES }).projects), ['books']);
doc.projects.books.universes.harbor.playground = ['Elsewhere/Playground/'];
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-outside-book/);
doc.projects.books = { ...mappingEntry(), root: '/forbidden' };
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/);
});
test('C2-04: an otherwise valid publish grant cannot name a library repo', async t => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
const { rmSync } = await import('node:fs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root);
doc.projects.books = { ...mappingEntry(), remote: 'https://git.example/writer/books' };
doc.roles.coder.identity = { name: 'Coder', email: '[email protected]', gitea: { url: 'https://git.example', user: 'acme-coder' } };
doc.roles.coder.publish = ['books'];
refuses(() => validateBusinessDocument(doc, join(root, 'businesses/acme.json'), { rolesDir: REPO_ROLES }), /publish.*library/);
});
function refuses(fn, pattern, exitCode = 2) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
+18
View File
@@ -8,6 +8,24 @@ import { businessDoc, REPO, rolesCopy, systemConfig, tmp, tokenFile, writeJson }
const CLI = join(REPO, "packages", "business", "src", "cli.mjs");
test('C2-04: business validate and resolve skip a library before code projects and credential checks', async t => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
const { rmSync } = await import('node:fs');
const s = setup(doc => { doc.projects = { books: mappingEntry(), ...doc.projects }; });
t.after(() => rmSync(s.root, { recursive: true, force: true }));
writeJson(projectFilePath(s.doc.projects.stack.root), { projectVersion: 1, id: 'stack', vars: { 'tracker.project': 3 } });
const checked = run(s, 'validate', 'acme');
assert.equal(checked.code, 0, checked.err);
assert.deepEqual(JSON.parse(checked.out).projects, { books: 'library', stack: 'valid' });
assert.doesNotMatch(checked.err, /project books: no project file/);
const library = run(s, 'resolve', 'acme', 'coder', '--project', 'books');
assert.equal(library.code, 0, library.err);
assert.equal(JSON.parse(library.out).project, null);
const code = run(s, 'resolve', 'acme', 'coder', '--project', 'stack');
assert.equal(code.code, 0, code.err);
assert.equal(JSON.parse(code.out).vars['tracker.project'], 3);
});
// A complete scratch setup: system config, business file, roles copy.
// HOME points into the scratch tree too, so nothing reads the real one.
function setup(mutate) {
@@ -20,6 +20,16 @@ after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })
const harnessOn = (doc, names = ["pi", "claude-code"]) => { doc.plugins = { ...(doc.plugins ?? {}), harness: names }; };
const LEVELS = { pi: ["off", "minimal", "low", "medium", "high", "xhigh", "max"], "claude-code": ["low", "medium", "high", "xhigh", "max"] };
test('C2-04: instance settings skip a library while checking a later code project', async () => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
const f = fixture(doc => { doc.projects = { books: mappingEntry(), ...doc.projects }; });
writeJson(projectFilePath(f.doc.projects.stack.root), { projectVersion: 1, id: 'stack', vars: { 'tracker.project': 3 } });
const before = f.settings.read('acme');
f.settings.set('acme', { revision: before.revision, instance: 'gate-215', op: 'add', definition: 'merge-gate' }, 'jason');
assert.deepEqual(JSON.parse(readFileSync(f.file, 'utf8')).projects.books, mappingEntry());
assert.ok(f.settings.read('acme').instances['gate-215']);
});
function fixture(mutate, { levels } = {}) {
const root = tmp();
roots.push(root);
@@ -0,0 +1,68 @@
// C2-04 M9: use the writer directly, rather than relying on broker key filtering.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { organizationWriter } from '../src/organization.mjs';
import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
function fixture(t) {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root);
const books = mappingEntry();
books.universes.hybrid = { kind: 'hybrid', name: 'An invented crossover',
sources: ['harbor', 'second'], books: ['Hybrid Book'], playground: [], constraints: [], notes: 'Fixture only' };
doc.projects = { books, ...doc.projects };
const dir = join(root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), doc);
const writer = organizationWriter({ dir, rolesDir: rolesCopy(root) });
// Give both snapshots an existing ordinary committed receipt to preserve.
doc.vars['tracker.pollSeconds'] = 61;
writer.write('acme', doc, { revision: writer.read('acme').revision, actor: 'jason' });
const log = writer.logFile('acme');
return { root, file, doc, writer, log };
}
function protectedWrite(f, change) {
const before = readFileSync(f.file), logBefore = readFileSync(f.log);
const next = structuredClone(f.writer.read('acme').document); change(next);
assert.throws(() => f.writer.write('acme', next, { revision: f.writer.read('acme').revision, actor: 'jason' }),
{ code: 'organization-projects-protected' });
assert.deepEqual(readFileSync(f.file), before);
assert.deepEqual(readFileSync(f.log), logBefore);
}
test('C2-04 M9: changed playground and hybrid entries leave business and changes.jsonl byte-identical', t => {
const f = fixture(t);
protectedWrite(f, next => { next.projects.books.universes.harbor.playground = ['Book One/New Playground/']; });
protectedWrite(f, next => { next.projects.books.universes.hybrid.notes = 'Changed hybrid rule'; });
});
test('C2-04 M9: code projects, additions, deletions and array changes are protected too', t => {
const f = fixture(t);
protectedWrite(f, next => { next.projects.stack.root = join(f.root, 'different-code'); });
protectedWrite(f, next => { next.projects.extra = { root: join(f.root, 'extra') }; });
protectedWrite(f, next => { delete next.projects.stack; });
protectedWrite(f, next => { next.projects.books.universes.hybrid.sources.reverse(); });
});
test('C2-04 M9: canonical key order is allowed and a non-project setter still records its change', t => {
const f = fixture(t), old = f.writer.read('acme');
const next = structuredClone(old.document);
next.projects = Object.fromEntries(Object.entries(next.projects).reverse());
next.vars['tracker.pollSeconds'] = 62;
f.writer.write('acme', next, { revision: old.revision, actor: 'jason' });
assert.equal(f.writer.read('acme').document.vars['tracker.pollSeconds'], 62);
const receipt = JSON.parse(readFileSync(f.log, 'utf8').trim().split('\n').at(-1));
assert.equal(receipt.state, 'committed');
assert.deepEqual(receipt.changes.map(c => c.path), ['vars.tracker.pollSeconds']);
});
test('C2-04 M9: a serialization hook cannot change projects after the canonical comparison', t => {
const f = fixture(t);
protectedWrite(f, next => {
const changed = structuredClone(next.projects);
changed.books.universes.hybrid.notes = 'Serialized change';
Object.defineProperty(next.projects, 'toJSON', { value: () => changed });
});
});
+10
View File
@@ -28,6 +28,16 @@ function setup(mutate, projectDoc) {
const resolve = (s, instance) => resolveInstance({ system: s.system, business: s.business, project: s.project, instance });
test('C2-04: a library first in projects does not hide a later project layer', async t => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
const { rmSync } = await import('node:fs');
const s = setup(doc => { doc.projects = { books: mappingEntry(), ...doc.projects }; }, { vars: { 'tracker.project': 7 } });
t.after(() => rmSync(s.root, { recursive: true, force: true }));
const result = resolve(s, 'coder');
assert.equal(result.project, 'stack');
assert.equal(result.vars['tracker.project'], 7);
});
test("systemVars flattens the validated config", () => {
const vars = systemVars({ environment: "development", dataRoot: "/d", execution: { backend: "docker", provider: "zai", model: "m", adapter: "pi" } });
assert.deepEqual({ ...vars }, {
+1
View File
@@ -59,6 +59,7 @@ function trackerFor(system, b, warn) {
if (base["tracker.baseUrl"] === undefined) return null;
const named = [];
for (const [id, entry] of Object.entries(b.projects)) {
if (entry.kind === 'library') continue;
if (!existsSync(projectFilePath(entry.root))) continue;
const project = loadProject(entry.root);
if (project.id !== id) throw new CliError(`project file ${project.file} has id ${project.id}, but business ${b.id} declares it as ${id}`, 3);
+1 -1
View File
@@ -351,7 +351,7 @@ export function createLauncher({
mkdirSync(workspace, { recursive: true, mode: 0o700 });
let checkouts;
try {
checkouts = await ensureCheckouts({ workspace, projects: b.projects, env });
checkouts = await ensureCheckouts({ workspace, projects: Object.fromEntries(Object.entries(b.projects).filter(([, entry]) => entry.kind !== 'library')), env });
} catch (e) {
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${e.message}`);
throw e;
+10
View File
@@ -30,6 +30,16 @@ const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mj
const HOST = fileURLToPath(new URL("./fixtures/launch-host.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
test('C2-04: launcher workspace clones code but never the library', { skip, timeout: 30000 }, async t => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
const f = await setup(t, doc => { doc.projects = { books: mappingEntry(), ...doc.projects }; return doc; });
await f.launcher.launchPm();
const workspace = workspaceDir(f.f.dataRoot, 'acme', 'chief');
assert.equal(existsSync(join(workspace, 'stack', '.git')), true);
assert.equal(existsSync(join(workspace, 'books')), false);
assert.equal(await f.close(), 0);
});
// chief opus, coder and reviewer sonnet, one of each family; cto is listed but
// runs the system model, which is in no family. The chief of staff is the launcher.
function edit(doc) {
+1 -1
View File
@@ -306,7 +306,7 @@ for(const [id,store] of [[F,'// a copy from before SCHEMA_VERSION\n'],[A,storeTe
for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?`// fixture Console, never run\n// serve accepts '--tester-port'\n// serve accepts '--proof-header' (row 178)\n${[C1,C].includes(id)?"// serve accepts '--sso' (row 192)\n":''}`:'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644],...ssoFiles(id).map(([f,text])=>[f,text,0o644])]){
mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text,{mode});
}
for(const p of ['packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
for(const p of ['packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
execFileSync('chmod',['-R','a-w',d]);
}
for(const [name,fn] of [['systemctl',systemctl],['journalctl',journalctl],['systemd-analyze',systemdAnalyze]])writeFileSync(join(SBIN,name),script(fn),{mode:0o755});
+1 -1
View File
@@ -1779,7 +1779,7 @@ export class Store {
const RCOPIES=join(base,'rcopies');mkdirSync(RCOPIES);
for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],[C1,'3g',['3f']]]){
const d=join(RCOPIES,id);
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/bus/src/business.mjs','packages/plugins/src/kinds.mjs','packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/bus/src/business.mjs','packages/plugins/src/kinds.mjs','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755});
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
execFileSync('chmod',['-R','a-w',d]);
+14
View File
@@ -12,7 +12,20 @@ import { FakeVikunja } from "../../tasks/src/fake.mjs";
import { SCOPES } from "../../tasks/tests/world.mjs";
import { fixture, tmp } from "./helpers.mjs";
test('C2-04: a library-only business with tracker.baseUrl boots without a project tracker', async t => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
const f = fixture(tmp(t), 'acme', doc => { doc.projects = { books: mappingEntry() }; return doc; });
const warnings = [];
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env, warn: text => warnings.push(text) });
assert.equal(boot.trackers, undefined);
assert.ok(warnings.some(text => text.includes('no project file sets tracker.project')));
const host = await startHost({ boot, business: 'acme', log: () => {} });
t.after(() => host.close(0));
assert.equal(await host.close(0), 0);
});
test("bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja", async (t) => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
const root = tmp(t);
const fake = new FakeVikunja();
const srv = await fake.listen();
@@ -26,6 +39,7 @@ test("bootConfig trackers reach the S3 adapter in the real broker child, which g
fake.install(project);
const f = fixture(root, "acme", (doc) => {
doc.projects = { books: mappingEntry(), ...doc.projects };
doc.vars["tracker.baseUrl"] = srv.url;
doc.tracker.sync.botId = bots.sync;
// The chief of staff (row 119) has no tracker bot.
+1
View File
@@ -6,3 +6,4 @@ export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './l
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
export { survey, formatSurvey, surveyMain } from './survey.mjs';
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
+257
View File
@@ -0,0 +1,257 @@
// C2-04: host-only bare library storage. No remote, fetch, credential or
// working tree. The broker receives these functions through the runtime.
import { constants, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync,
openSync, readSync, readdirSync, realpathSync, rmSync } from 'node:fs';
import { dirname, isAbsolute, join, normalize } from 'node:path';
import { execFile, spawn } from 'node:child_process';
import { projectRepo, noLinks } from '../../project/src/paths.mjs';
import { git } from '../../project/src/git.mjs';
import { checkMapping } from './mapping.mjs';
import { classify } from './scope.mjs';
import { LibraryError, RULES } from './problems.mjs';
import { listTree, readLibrary, readTexts } from './reader.mjs';
export const SYNC_STATE_FILE = 'mosaic-library-sync.json';
const LOCK_FILE = 'mosaic-library-sync.lock';
const STATE_MAX = 8 * 1024 * 1024;
const SHA = /^[a-f0-9]{40}$/;
const fail = (code, message = code) => { throw new LibraryError(code, message); };
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const keys = (value, expected) => object(value) && Object.keys(value).length === expected.length
&& Object.keys(value).every(key => expected.includes(key));
const optional = (value, check) => value === null || check(value);
const utc = value => {
if (typeof value !== 'string') return false;
try { return new Date(value).toISOString() === value; } catch { return false; }
};
const empty = branch => ({ version: 1, branch, served: null, lastFetch: null, lastError: null, diverged: null, problems: [] });
function contextEntry(context) {
const { business, project } = context;
if (!Object.hasOwn(business?.projects ?? {}, project)) fail('project-not-declared');
const entry = business.projects[project];
if (entry?.kind !== 'library') fail('library-mapping-invalid', 'expected a library project');
return checkMapping(entry);
}
export function mirrorPath(context) {
contextEntry(context);
try { return projectRepo(context, { allowLibrary: true }); }
catch (error) { throw new LibraryError(error.code ?? 'library-git', 'unsafe mirror path'); }
}
function ownedRoot(mirror) {
try {
if (typeof mirror !== 'string' || !isAbsolute(mirror) || normalize(mirror) !== mirror) throw Error();
noLinks(mirror);
const st = lstatSync(mirror);
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077) || realpathSync(mirror) !== mirror) throw Error();
} catch { fail('library-git', 'expected a private owned bare mirror'); }
}
function privateFile(st) {
return st.isFile() && st.uid === process.getuid() && st.nlink === 1 && (st.mode & 0o077) === 0;
}
async function inspectMirrorUnchecked(mirror) {
ownedRoot(mirror);
const normal = ['HEAD', 'config', 'description', 'hooks', 'info', 'objects', 'refs', 'logs', 'packed-refs', 'shallow', 'FETCH_HEAD', 'ORIG_HEAD'];
const allowed = new Set([...normal, ...normal.filter(name => !['hooks', 'info', 'objects', 'refs', 'logs', 'description'].includes(name)).map(name => name + '.lock'), SYNC_STATE_FILE, LOCK_FILE]);
const top = readdirSync(mirror);
if (top.some(name => !allowed.has(name)) || ['HEAD', 'config', 'objects', 'refs'].some(name => !top.includes(name)))
fail('library-git', 'unexpected bare mirror layout');
for (const path of ['commondir', 'objects/info/alternates', 'objects/info/http-alternates'])
if (existsSync(join(mirror, path))) fail('library-git', 'Git metadata indirection refused');
const walk = (folder, topLevel = false) => {
for (const name of readdirSync(folder)) {
const file = join(folder, name); let st;
try { st = lstatSync(file); }
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
const state = topLevel && [SYNC_STATE_FILE, LOCK_FILE].includes(name);
if (state) { if (!privateFile(st)) fail('library-state-invalid', 'unsafe state or lock metadata'); }
else if (st.isDirectory() && !st.isSymbolicLink()) walk(file);
else if (!st.isFile() || st.nlink !== 1 || st.uid !== process.getuid()) fail('library-git', 'unsafe Git metadata');
}
};
walk(mirror, true);
if (!top.includes(LOCK_FILE)) fail('library-state-invalid', 'mirror lock file missing');
if (lstatSync(join(mirror, 'config')).size > 65536) fail('library-git', 'Git config too large');
let config;
try { config = await git(mirror, ['config', '--no-includes', '--file', join(mirror, 'config'), '--list', '--null']); }
catch { fail('library-git', 'cannot inspect mirror config'); }
const values = { 'core.repositoryformatversion': ['0'], 'core.filemode': ['true', 'false'], 'core.bare': ['true'],
'core.logallrefupdates': ['true', 'false'], 'core.ignorecase': ['true', 'false'] };
let bare = false;
for (const item of config.split('\0').filter(Boolean)) {
const at = item.indexOf('\n'), key = item.slice(0, at), value = item.slice(at + 1);
if (key === 'core.bare' && value === 'true') bare = true;
if (at < 0 || !values[key]?.includes(value)) fail('library-git', 'unsupported bare mirror config');
}
if (!bare) fail('library-git', 'expected a bare repository');
}
async function inspectMirror(mirror) {
try { await inspectMirrorUnchecked(mirror); }
catch (error) {
if (error instanceof LibraryError) throw error;
fail('library-git', 'unreadable bare mirror metadata');
}
}
export async function ensureMirror(context) {
const mirror = mirrorPath(context);
if (existsSync(mirror)) { await inspectMirror(mirror); return mirror; }
const parent = dirname(mirror); mkdirSync(parent, { recursive: true, mode: 0o700 }); noLinks(parent);
const staged = mkdtempSync(join(parent, '.library-init-'));
const original = lstatSync(staged);
const isOwnStage = st => st.isDirectory() && st.uid === process.getuid() && st.dev === original.dev && st.ino === original.ino;
try {
await git(staged, ['init', '--bare', '--initial-branch=served', '--template=']);
const fd = openSync(join(staged, LOCK_FILE), constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
closeSync(fd);
await inspectMirror(staged);
// Coreutils versions disagree on the status of a skipped no-clobber
// rename. The original inode, rather than that status, decides who won.
const moveError = await new Promise(resolve => execFile('/usr/bin/mv', ['--no-clobber', '--no-target-directory', staged, mirror],
{ timeout: 5000, maxBuffer: 65536, env: { PATH: '/usr/bin:/bin', LC_ALL: 'C' } }, error => resolve(error)));
let remaining;
try { remaining = lstatSync(staged); } catch (error) { if (error.code !== 'ENOENT') throw error; }
if (remaining) {
if (!isOwnStage(remaining)) fail('library-git', 'staging directory was replaced');
// A skipped rename is valid only when another intact mirror won.
// Signals, timeouts and spawn failures are always genuine errors.
if (moveError && (typeof moveError.code !== 'number' || moveError.killed || moveError.signal)) throw moveError;
} else {
if (!isOwnStage(lstatSync(mirror))) fail('library-git', 'mirror publication identity mismatch');
if (moveError) throw moveError;
}
await inspectMirror(mirror);
return mirror;
} catch (error) {
if (error instanceof LibraryError) throw error;
fail('library-git', 'bare mirror initialization refused');
} finally {
let remaining;
try { remaining = lstatSync(staged); } catch (error) { if (error.code !== 'ENOENT') throw error; }
if (remaining) {
if (!isOwnStage(remaining)) fail('library-git', 'refusing to remove replaced staging directory');
rmSync(staged, { recursive: true });
}
}
}
// Each acquisition opens a distinct description. flock(1) inherits it as
// fd 3 and exits after acquisition; this parent's fd retains the lock.
export async function withLibraryLock(mirror, operation) {
ownedRoot(mirror);
let fd;
try {
try {
fd = openSync(join(mirror, LOCK_FILE), constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK);
if (!privateFile(fstatSync(fd))) fail('library-state-invalid', 'unsafe library lock');
} catch { fail('library-state-invalid', 'library lock missing or unsafe'); }
await new Promise((resolve, reject) => {
const child = spawn('/usr/bin/flock', ['--exclusive', '--wait', '5', '--conflict-exit-code', '75', '3'],
{ env: { PATH: '/usr/bin:/bin', LC_ALL: 'C' }, stdio: ['ignore', 'ignore', 'ignore', fd] });
child.once('error', () => reject(new LibraryError('library-state-invalid', 'host requires /usr/bin/flock')));
child.once('close', code => code === 0 ? resolve() : reject(new LibraryError(code === 75 ? 'library-sync-busy' : 'library-state-invalid', 'library lock acquisition refused')));
});
try {
const heldStat = fstatSync(fd), pathStat = lstatSync(join(mirror, LOCK_FILE));
if (!privateFile(pathStat) || pathStat.ino !== heldStat.ino || pathStat.dev !== heldStat.dev) throw Error();
} catch { fail('library-state-invalid', 'library lock was replaced'); }
let held = true;
const pending = new Set();
const readSyncState = options => {
if (!held) return Promise.reject(new LibraryError('library-state-invalid', 'library lock scope expired'));
const read = readStateHeld(mirror, options).finally(() => pending.delete(read));
pending.add(read); read.catch(() => {});
return read;
};
try { return await operation({ readSyncState }); }
finally {
held = false;
// An accidentally unawaited scoped read must finish before this fd
// releases the lock. No new read can start after the callback ends.
await Promise.allSettled([...pending]);
}
} finally { if (fd !== undefined) closeSync(fd); }
}
export async function servedCommit(mirror) {
await inspectMirror(mirror);
try {
const refs = await git(mirror, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads/served']);
const line = refs.split('\n').find(line => line.startsWith('refs/heads/served '));
if (!line) {
if (existsSync(join(mirror, 'refs/heads/served'))) fail('library-state-invalid', 'invalid served ref');
return null;
}
const commit = line.slice('refs/heads/served '.length);
if (!SHA.test(commit) || await git(mirror, ['cat-file', '-t', commit]) !== 'commit') fail('library-state-invalid', 'served ref must name a commit');
return commit;
} catch { fail('library-state-invalid', 'served ref is unreadable'); }
}
function validProblem(value) {
return object(value) && Object.keys(value).every(key => ['code', 'path', 'message', 'line'].includes(key))
&& Object.hasOwn(RULES, value.code) && value.message === RULES[value.code]
&& typeof value.path === 'string' && value.path.length > 0 && value.path.length <= 4096 && !value.path.includes('\0')
&& (!Object.hasOwn(value, 'line') || Number.isSafeInteger(value.line) && value.line > 0);
}
async function readStateHeld(mirror, { branch } = {}) {
if (typeof branch !== 'string' || !branch) fail('library-state-invalid', 'configured branch required');
const served = await servedCommit(mirror);
let fd, value;
try {
try { fd = openSync(join(mirror, SYNC_STATE_FILE), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (error) {
if (error.code === 'ENOENT' && served === null) return empty(branch);
fail('library-state-invalid', 'sync state missing or unsafe');
}
const st = fstatSync(fd);
if (!privateFile(st) || st.size > STATE_MAX) fail('library-state-invalid', 'sync state must be bounded and private');
const bytes = Buffer.alloc(Math.min(st.size + 1, STATE_MAX + 1));
let used = 0;
while (used < bytes.length) { const read = readSync(fd, bytes, used, bytes.length - used, null); if (!read) break; used += read; }
if (used > STATE_MAX || used > st.size) fail('library-state-invalid', 'state changed during bounded read');
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, used)));
} catch { fail('library-state-invalid', 'invalid sync state'); }
finally { if (fd !== undefined) closeSync(fd); }
if (!keys(value, ['version', 'branch', 'served', 'lastFetch', 'lastError', 'diverged', 'problems'])
|| value.version !== 1 || value.branch !== branch || value.served !== served
|| !optional(value.served, v => typeof v === 'string' && SHA.test(v)) || !optional(value.lastFetch, utc)
|| !optional(value.lastError, v => keys(v, ['code', 'at']) && typeof v.code === 'string' && /^[a-z-]{1,64}$/.test(v.code) && utc(v.at))
|| !optional(value.diverged, v => keys(v, ['commit', 'at']) && typeof v.commit === 'string' && SHA.test(v.commit) && utc(v.at))
|| !Array.isArray(value.problems) || !value.problems.every(validProblem))
fail('library-state-invalid', 'sync state does not match its contract or served ref');
return value;
}
export async function readSyncState(mirror, options) {
return withLibraryLock(mirror, lock => lock.readSyncState(options));
}
export async function libraryStatus(context) {
const mapping = contextEntry(context), mirror = mirrorPath(context);
return { project: context.project, ...await readSyncState(mirror, { branch: mapping.branch }) };
}
export async function readLibraryFile(context) {
if (!object(context) || Object.keys(context).some(key => !['business', 'project', 'dataRoot', 'path'].includes(key)))
fail('project-read-path', 'unknown library read arguments');
const mapping = contextEntry(context), path = context.path;
if (typeof path !== 'string' || !path || isAbsolute(path) || path.includes('\0') || path.split('/').some(p => !p || p === '.' || p === '..')
|| classify(mapping, path).scope === 'unmapped') fail('project-read-path', 'expected a mapped library note');
const mirror = mirrorPath(context);
// Snapshot under the publication lock, then read only immutable objects.
const state = await readSyncState(mirror, { branch: mapping.branch });
if (!state.served) fail('library-commit', 'library has never been synced');
const read = await readLibrary({ repo: mirror, commit: state.served, mapping });
if (!read.files.some(file => file.path === path)) fail('project-read-missing', 'file is absent or excluded at served commit');
const entry = (await listTree(mirror, state.served)).find(entry => entry.path === path);
const texts = await readTexts(mirror, [entry]);
if (!texts.has(path)) fail('project-read-missing', 'file is not readable');
return { project: context.project, path, commit: state.served, content: texts.get(path) };
}
+247
View File
@@ -0,0 +1,247 @@
// C2-04: local made-up repositories only. No remote credential or live config.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { mappingEntry, repo, git, BASE, MARKER } from './helpers.mjs';
import * as api from '../src/mirror.mjs';
const NOW = '2026-10-21T00:00:00.000Z';
const MOD = new URL('../src/mirror.mjs', import.meta.url).href;
const state = (served = null) => ({ version: 1, branch: 'main', served, lastFetch: null, lastError: null, diverged: null, problems: [] });
const stateFile = mirror => join(mirror, api.SYNC_STATE_FILE);
async function fixture(t, files = BASE) {
const f = repo(t, files), dataRoot = join(f.root, 'data');
const context = { business: { id: 'acme', projects: { books: mappingEntry(), code: { root: f.repo } } }, project: 'books', dataRoot };
const mirror = await api.ensureMirror(context);
const writeState = value => writeFileSync(stateFile(mirror), JSON.stringify(value), { mode: 0o600 });
const seed = (commit = f.commit) => {
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, `${commit}:refs/heads/served`);
writeState(state(commit));
};
return { ...f, dataRoot, context, mirror, writeState, seed };
}
test('C2-04 mirror: exact default path, bare without checkout or remote, and idempotent initialization', async t => {
const f = await fixture(t);
assert.equal(api.SYNC_STATE_FILE, 'mosaic-library-sync.json');
assert.equal(api.mirrorPath(f.context), join(f.dataRoot, 'projects/acme/books'));
assert.equal(f.mirror, api.mirrorPath(f.context));
assert.equal(git(f.mirror, 'rev-parse', '--is-bare-repository'), 'true');
assert.equal(git(f.mirror, 'remote'), '');
assert.equal(existsSync(join(f.mirror, '.git')), false);
const lock = join(f.mirror, 'mosaic-library-sync.lock'), st = lstatSync(lock);
assert.equal(st.mode & 0o777, 0o600);
assert.equal(st.nlink, 1);
assert.equal(await api.ensureMirror(f.context), f.mirror);
assert.equal(lstatSync(lock).ino, st.ino, 'the lock inode is stable');
assert.equal(await api.servedCommit(f.mirror), null);
assert.deepEqual(await api.libraryStatus(f.context), { project: 'books', ...state() });
});
test('C2-04 mirror: simultaneous creators publish one intact mirror and leave no staging paths', async t => {
const f = repo(t), dataRoot = join(f.root, 'data');
const c = { business: { id: 'acme', projects: { books: mappingEntry() } }, project: 'books', dataRoot };
const results = await Promise.all(Array.from({ length: 4 }, () => api.ensureMirror(c)));
assert.deepEqual([...new Set(results)], [join(dataRoot, 'projects/acme/books')]);
assert.deepEqual(readdirSync(join(dataRoot, 'projects/acme')), ['books']);
assert.equal(git(results[0], 'rev-parse', '--is-bare-repository'), 'true');
});
test('C2-04 mirror: skipped publication accepts both coreutils statuses without removing the winner', { timeout: 15000 }, async t => {
for (const status of [0, 1]) {
const f = repo(t), dataRoot = join(f.root, 'data');
const context = { business: { id: 'acme', projects: { books: mappingEntry() } }, project: 'books', dataRoot };
const creator = child(t, `import cp from 'node:child_process';import {cpSync,chmodSync,lstatSync,readdirSync} from 'node:fs';import {dirname} from 'node:path';import {syncBuiltinESMExports} from 'node:module';
const original=cp.execFile;let winner;
cp.execFile=(command,args,options,callback)=>{if(command!=='/usr/bin/mv')return original(command,args,options,callback);
cpSync(args[2],args[3],{recursive:true});chmodSync(args[3],0o700);winner=lstatSync(args[3]).ino;
const error=${status} ? Object.assign(Error('fixture no-clobber diagnostic'),{code:${status}}) : null;
queueMicrotask(()=>callback(error));};syncBuiltinESMExports();
const {ensureMirror}=await import(${JSON.stringify(MOD)});const mirror=await ensureMirror(${JSON.stringify(context)});
process.send({same:lstatSync(mirror).ino===winner,entries:readdirSync(dirname(mirror))});process.disconnect();`);
const [result] = await Promise.race([once(creator, 'message'), creator.finished.then(result => { throw Error(JSON.stringify(result)); })]);
assert.deepEqual(result, { same: true, entries: ['books'] });
assert.deepEqual(await creator.finished, { code: 0, stderr: '' });
}
});
test('C2-04 mirror: preserve occupied directories and reject symlink ancestors and code overlaps', async t => {
const f = repo(t), dataRoot = join(f.root, 'data');
const c = { business: { id: 'acme', projects: { books: mappingEntry() } }, project: 'books', dataRoot };
const path = join(dataRoot, 'projects/acme/books'); mkdirSync(path, { recursive: true });
writeFileSync(join(path, 'keep'), 'made-up human file');
await assert.rejects(api.ensureMirror(c));
assert.equal(readFileSync(join(path, 'keep'), 'utf8'), 'made-up human file');
rmSync(dataRoot, { recursive: true }); symlinkSync(f.repo, dataRoot);
assert.throws(() => api.mirrorPath(c), { code: 'project-path' });
unlinkSync(dataRoot);
c.business.projects.code = { root: dataRoot };
assert.throws(() => api.mirrorPath(c), { code: 'project-path' });
});
test('C2-04 mirror: existing metadata accepts only bare Git plus state and the precreated lock', async t => {
const f = await fixture(t);
writeFileSync(join(f.mirror, 'unknown-note'), 'do not silently allow this');
await assert.rejects(api.ensureMirror(f.context), { code: 'library-git' });
assert.equal(readFileSync(join(f.mirror, 'unknown-note'), 'utf8'), 'do not silently allow this');
unlinkSync(join(f.mirror, 'unknown-note'));
git(f.mirror, 'config', 'remote.origin.url', 'https://example.invalid/fixture.git');
await assert.rejects(api.ensureMirror(f.context), { code: 'library-git' });
git(f.mirror, 'config', '--remove-section', 'remote.origin');
unlinkSync(join(f.mirror, 'mosaic-library-sync.lock'));
await assert.rejects(api.ensureMirror(f.context), { code: 'library-state-invalid' });
assert.equal(existsSync(join(f.mirror, 'mosaic-library-sync.lock')), false);
});
test('C2-04 state: missing state is empty only while the served ref is absent', async t => {
const f = await fixture(t);
assert.deepEqual(await api.readSyncState(f.mirror, { branch: 'main' }), state());
f.seed(); unlinkSync(stateFile(f.mirror));
await assert.rejects(api.readSyncState(f.mirror, { branch: 'main' }), { code: 'library-state-invalid' });
});
test('C2-04 state: valid problems and sync metadata return no content and change no state bytes', async t => {
const f = await fixture(t); f.seed();
const value = { ...state(f.commit), lastFetch: NOW, lastError: { code: 'library-git', at: NOW },
diverged: { commit: 'a'.repeat(40), at: NOW },
problems: [{ code: 'R4', path: 'Book One/Chapters/A.md', line: 2, message: 'link resolves to no file or to more than one' }] };
f.writeState(value); const before = readFileSync(stateFile(f.mirror));
assert.deepEqual(await api.readSyncState(f.mirror, { branch: 'main' }), value);
assert.equal(JSON.stringify(await api.libraryStatus(f.context)).includes(MARKER), false);
assert.deepEqual(readFileSync(stateFile(f.mirror)), before);
});
test('C2-04 state: strict keys, types, times, branch, commit and fixed R messages fail closed', async t => {
const f = await fixture(t); f.seed();
const mutations = [
s => { s.extra = true; }, s => { s.version = 2; }, s => { s.branch = 'other'; },
s => { s.served = 'a'.repeat(40); }, s => { s.served = null; }, s => { s.served = 'ABC'; },
s => { s.lastFetch = 'yesterday'; }, s => { s.lastFetch = 1; },
s => { s.lastError = { code: 'bad code', at: NOW }; },
s => { s.lastError = { code: 'library-git', at: NOW, stderr: MARKER }; },
s => { s.diverged = { commit: 'not-a-sha', at: NOW }; },
s => { s.problems = [{ code: 'R1', path: 'Book One/A.md', message: MARKER }]; },
s => { s.problems = [{ code: 'R4', path: 'Book One/A.md', line: 0, message: 'link resolves to no file or to more than one' }]; },
s => { s.problems = null; },
];
for (const mutate of mutations) {
const s = state(f.commit); mutate(s); f.writeState(s);
const before = readFileSync(stateFile(f.mirror));
await assert.rejects(api.readSyncState(f.mirror, { branch: 'main' }), { code: 'library-state-invalid' });
assert.deepEqual(readFileSync(stateFile(f.mirror)), before);
}
writeFileSync(stateFile(f.mirror), '{');
await assert.rejects(api.readSyncState(f.mirror, { branch: 'main' }), { code: 'library-state-invalid' });
});
test('C2-04 state: symlink, hardlink, non-private state and missing lock refuse without replacement', async t => {
const f = await fixture(t); f.seed();
const outside = join(f.root, 'outside.json'); writeFileSync(outside, JSON.stringify(state(f.commit)), { mode: 0o600 });
unlinkSync(stateFile(f.mirror)); symlinkSync(outside, stateFile(f.mirror));
await assert.rejects(api.readSyncState(f.mirror, { branch: 'main' }), { code: 'library-state-invalid' });
unlinkSync(stateFile(f.mirror)); linkSync(outside, stateFile(f.mirror));
await assert.rejects(api.readSyncState(f.mirror, { branch: 'main' }), { code: 'library-state-invalid' });
unlinkSync(stateFile(f.mirror)); f.writeState(state(f.commit)); chmodSync(stateFile(f.mirror), 0o644);
await assert.rejects(api.readSyncState(f.mirror, { branch: 'main' }), { code: 'library-state-invalid' });
chmodSync(stateFile(f.mirror), 0o600); unlinkSync(join(f.mirror, 'mosaic-library-sync.lock'));
await assert.rejects(api.readSyncState(f.mirror, { branch: 'main' }), { code: 'library-state-invalid' });
assert.equal(existsSync(join(f.mirror, 'mosaic-library-sync.lock')), false);
});
test('C2-04 file: served commit, not HEAD or a caller commit, owns the exact returned text', async t => {
const f = await fixture(t); f.seed();
const path = 'Book One/Characters/Tomas.md';
writeFileSync(join(f.repo, path), 'newer text\n'); git(f.repo, 'commit', '-qam', 'newer');
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', f.mirror, 'HEAD:refs/heads/unserved');
git(f.mirror, 'symbolic-ref', 'HEAD', 'refs/heads/unserved');
assert.deepEqual(await api.readLibraryFile({ ...f.context, path }), { project: 'books', path, commit: f.commit, content: BASE[path] });
await assert.rejects(api.readLibraryFile({ ...f.context, path, commit: git(f.repo, 'rev-parse', 'HEAD') }), { code: 'project-read-path' });
for (const path of ['../escape.md', '/absolute.md', 'Book One/Board.canvas', 'Inbox Note.md'])
await assert.rejects(api.readLibraryFile({ ...f.context, path }), { code: 'project-read-path' });
await assert.rejects(api.readLibraryFile({ ...f.context, path: 'Book One/Missing.md' }), { code: 'project-read-missing' });
});
test('C2-04 file: R1, R2 and R7 exclusions and never-synced mirrors cannot return file bodies', async t => {
const f = await fixture(t, { ...BASE, 'Book One/Bad.md': { link: 'Characters/Tomas.md' },
'Book One/Executable.md': { mode: 0o755, text: MARKER },
'Book One/Twin.md': MARKER, 'Book One/twin.md': MARKER });
await assert.rejects(api.readLibraryFile({ ...f.context, path: 'Book One/Characters/Tomas.md' }), { code: 'library-commit' });
f.seed();
for (const path of ['Book One/Bad.md', 'Book One/Executable.md', 'Book One/Twin.md', 'Book One/twin.md'])
await assert.rejects(api.readLibraryFile({ ...f.context, path }), { code: 'project-read-missing' });
});
function child(t, source) {
const env = { ...process.env }; delete env.NODE_TEST_CONTEXT;
const p = spawn(process.execPath, ['--input-type=module', '-e', source], { env, stdio: ['ignore', 'pipe', 'pipe', 'ipc'] });
let stderr = ''; p.stderr.on('data', b => { stderr += b; }); p.stdout.resume();
p.finished = new Promise(resolve => { p.once('error', e => resolve({ error: e })); p.once('close', code => resolve({ code, stderr })); });
t.after(async () => { if (p.exitCode === null) p.kill('SIGKILL'); await p.finished; });
return p;
}
test('C2-04 lock: a first-sync and fast-forward reader wait for a consistent pair across processes', { timeout: 15000 }, async t => {
const f = await fixture(t);
// Transfer objects without serving them. No fetch or remote is added by the API.
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', f.mirror, 'HEAD:refs/heads/candidate');
for (const first of [true, false]) {
if (!first) { f.seed(); writeFileSync(join(f.repo, 'Book One/New.md'), '# New\n'); git(f.repo, 'add', '.'); git(f.repo, 'commit', '-qm', 'new');
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', f.mirror, 'HEAD:refs/heads/candidate'); }
const commit = git(f.repo, 'rev-parse', 'HEAD');
const writer = child(t, `import {withLibraryLock} from ${JSON.stringify(MOD)};import {writeFileSync} from 'node:fs';import {git} from ${JSON.stringify(new URL('../../project/src/git.mjs', import.meta.url).href)};
await withLibraryLock(${JSON.stringify(f.mirror)},async()=>{await git(${JSON.stringify(f.mirror)},['update-ref','refs/heads/served',${JSON.stringify(commit)}]);process.send('moved');await new Promise(r=>process.once('message',r));writeFileSync(${JSON.stringify(stateFile(f.mirror))},${JSON.stringify(JSON.stringify(state(commit)))},{mode:0o600});});process.disconnect();`);
const [message] = await once(writer, 'message'); assert.equal(message, 'moved');
let complete = false;
const read = api.readSyncState(f.mirror, { branch: 'main' }).then(value => { complete = true; return value; });
await new Promise(r => setTimeout(r, 50)); assert.equal(complete, false, 'the inconsistent pair is not observed');
writer.send('publish');
assert.deepEqual(await read, state(commit));
assert.deepEqual(await writer.finished, { code: 0, stderr: '' });
}
});
test('C2-04 lock: same-process waits, timeout code, callback expiry and throw release', { timeout: 15000 }, async t => {
const f = await fixture(t); let release, scoped;
const held = api.withLibraryLock(f.mirror, async lock => { scoped = lock.readSyncState; await new Promise(r => { release = r; }); });
const deadline = Date.now() + 5000;
while (!release && Date.now() < deadline) await new Promise(r => setTimeout(r, 5));
assert.equal(typeof release, 'function', 'first lock acquired before deadline');
await assert.rejects(api.readSyncState(f.mirror, { branch: 'main' }), { code: 'library-sync-busy' });
release(); await held;
await assert.rejects(scoped({ branch: 'main' }), { code: 'library-state-invalid' });
await assert.rejects(api.withLibraryLock(f.mirror, async () => { throw Error('fixture throw'); }), /fixture throw/);
assert.deepEqual(await api.readSyncState(f.mirror, { branch: 'main' }), state());
});
test('C2-04 lock: owner death releases the kernel lock without replacing its file', { timeout: 15000 }, async t => {
const f = await fixture(t), lock = join(f.mirror, 'mosaic-library-sync.lock'), ino = lstatSync(lock).ino;
const owner = child(t, `import {withLibraryLock} from ${JSON.stringify(MOD)};await withLibraryLock(${JSON.stringify(f.mirror)},async()=>{process.send('held');await new Promise(r=>process.once('message',r));});`);
const [message] = await once(owner, 'message'); assert.equal(message, 'held');
owner.kill('SIGKILL'); await owner.finished;
assert.deepEqual(await api.readSyncState(f.mirror, { branch: 'main' }), state());
assert.equal(lstatSync(lock).ino, ino);
});
test('C2-04 lock: a missing flock binary becomes a handled refusal', { timeout: 10000 }, async t => {
const f = await fixture(t);
const missing = child(t, `import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module';import {EventEmitter} from 'node:events';
const original=cp.spawn;cp.spawn=(command,...args)=>{if(command!=='/usr/bin/flock')return original(command,...args);const p=new EventEmitter();process.nextTick(()=>p.emit('error',Object.assign(Error('fixture missing flock'),{code:'ENOENT'})));return p;};syncBuiltinESMExports();
const {readSyncState}=await import(${JSON.stringify(MOD)});try{await readSyncState(${JSON.stringify(f.mirror)},{branch:'main'});process.send('unexpected success');}catch(e){process.send(e.code);}process.disconnect();`);
const [code] = await once(missing, 'message');
assert.equal(code, 'library-state-invalid');
assert.deepEqual(await missing.finished, { code: 0, stderr: '' });
});
test('C2-04 lock: an unawaited scoped read drains before the lock is released', { timeout: 10000 }, async t => {
const f = await fixture(t); let scoped, pending, complete = false;
await api.withLibraryLock(f.mirror, lock => {
scoped = lock.readSyncState;
pending = scoped({ branch: 'main' }).then(value => { complete = true; return value; });
});
assert.equal(complete, true);
assert.deepEqual(await pending, state());
await assert.rejects(scoped({ branch: 'main' }), { code: 'library-state-invalid' });
});
+4 -1
View File
@@ -31,14 +31,17 @@ const contains = (base, child) => {
const rel = relative(base, child);
return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
};
export function projectRepo({ business, project, dataRoot }) {
export function projectRepo({ business, project, dataRoot }, { allowLibrary = false } = {}) {
checkedId(business?.id); checkedId(project);
if (!Object.hasOwn(business?.projects ?? {}, project))
throw new ProjectError('project-not-declared', `business does not declare ${project}`);
absolute(dataRoot);
const entry = business.projects[project];
if (entry.kind === 'library' && !allowLibrary)
throw new ProjectError('project-invalid', 'library projects are not record repositories');
const repo = absolute(entry.repo ?? join(dataRoot, 'projects', business.id, project));
for (const project of Object.values(business.projects)) {
if (project.kind === 'library') continue;
const code = absolute(project.root);
if (contains(code, repo) || contains(repo, code))
throw new ProjectError('project-path', 'record repository and code checkout overlap');
+13
View File
@@ -24,6 +24,19 @@ async function ready(t, custom) {
return { c, repo: initialized.repo, commit: initialized.commit };
}
test('C2-04: record APIs refuse a library before creating a repository', async t => {
const c = context(t);
c.business.projects = { books: { kind: 'library' }, ...c.business.projects };
assert.equal(api.projectRepo(c), join(c.dataRoot, 'projects/acme/stack'));
const library = { ...c, project: 'books' };
assert.throws(() => api.projectRepo(library), { code: 'project-invalid' });
for (const author of ['jason', 'tester'])
await assert.rejects(api.initProject(library, { author }), { code: 'project-invalid' });
assert.equal(readdirSync(c.root).includes('data'), false);
c.business.projects.stack.root = c.dataRoot;
assert.throws(() => api.projectRepo(c), /overlap/);
});
test('init creates a checked versioned layout, valid templates and human-authored local history', async t => {
const { c, repo, commit } = await ready(t);
assert.equal(repo, join(c.dataRoot, 'projects/acme/stack'));
+3 -1
View File
@@ -311,7 +311,9 @@ async function update(root, dest, g) {
export async function ensureCheckouts({ workspace, projects, env = process.env, limits = GIT_LIMITS }) {
const g = (args, extra) => git(args, env, extra, limits);
const out = [];
for (const [project, { root }] of Object.entries(projects)) {
for (const [project, entry] of Object.entries(projects)) {
if (entry.kind === 'library') continue;
const { root } = entry;
const dest = join(workspace, project);
let exists = true;
try {
+9
View File
@@ -12,6 +12,15 @@ import { join } from "node:path";
import { deflateSync } from "node:zlib";
import { CONFIG_MAX_BYTES, CheckoutError, ensureCheckouts, gitEnv } from "../src/checkout.mjs";
test('C2-04: ensureCheckouts skips a library before a real clone and leaves no books path', async t => {
const w = world(t);
const result = await ensureCheckouts({ workspace: w.workspace, projects: { books: { kind: 'library' }, stack: { root: w.root } } });
assert.deepEqual(result.map(r => r.project), ['stack']);
assert.equal(result[0].outcome, 'cloned');
assert.equal(existsSync(join(w.workspace, 'books')), false);
assert.deepEqual(await ensureCheckouts({ workspace: w.workspace, projects: { books: { kind: 'library' } } }), []);
});
function tmp(t) {
const dir = mkdtempSync(join(tmpdir(), "mosaic-checkout-"));
t.after(() => {
+4 -2
View File
@@ -64,7 +64,7 @@ try {
// the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor).
// --send without a bus refuses to start rather than serve a composer that can't send.
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, busLine, system = null;
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, busLine, system = null;
try {
system = loadSystem();
const { dataRoot } = system;
@@ -77,6 +77,7 @@ try {
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
projectFiles = args => call('project.files', args);
projectFile = args => call('project.file', args);
libraryFile = args => call('library.file', args); libraryStatus = args => call('library.status', args);
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);}
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) {
@@ -97,7 +98,7 @@ try {
}
let server;
try {
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
} catch (err) { sso?.close(); throw err; }
let testerServer = null;
if (testerPort !== null) {
@@ -107,6 +108,7 @@ try {
testerServer = await startServer({ host: '127.0.0.1', port: testerPort, board: options.board,
tester: true, testerNoSignIn: ssoOn, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
chat: createChat({ call }),
+34
View File
@@ -0,0 +1,34 @@
// C2-04 only wires callbacks. HTTP routes and pages are later rows.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
import { HUMAN_CLI, TESTER_CLI } from '../../cli/src/transport.mjs';
test('C2-04 CLI: both listener callback pairs dispatch library verbs with their own transport', t => {
const root = tmp(t, 'library-cli-'), f = fixture(root), log = join(root, 'calls.jsonl');
writeFileSync(log, '');
const hook = join(root, 'hooks.mjs');
// The loader supplies a fixture bus and captures callbacks instead of
// opening an HTTP listener. Production imports and routes are unchanged.
const serveSource = "export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(o){if(typeof o.libraryFile!=='function'||typeof o.libraryStatus!=='function')throw Error('missing library callbacks');await o.libraryFile({project:'books',path:'Book One/A.md'});await o.libraryStatus({project:'books'});return {address:()=>({port:0}),close:()=>{}};}";
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,cli:o.cli??${JSON.stringify(HUMAN_CLI)}})+'\\n');return {};};`;
writeFileSync(hook, `import {registerHooks} from 'node:module';
registerHooks({load(url,context,next){
if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}};
if(url.endsWith('/webui/src/bus.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(busSource)}};
return next(url,context);
}});`);
const result = spawnSync(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname,
'serve', '--business', 'acme', '--port', '0', '--tester-port', '7444'],
{ env: { ...f.env, NODE_OPTIONS: `--import=${hook}` }, encoding: 'utf8', timeout: 10000 });
assert.equal(result.status, 0, result.stdout + result.stderr);
const calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
assert.deepEqual(calls.map(c => [c.verb, c.cli]), [
['library.file', HUMAN_CLI], ['library.status', HUMAN_CLI],
['library.file', TESTER_CLI], ['library.status', TESTER_CLI],
]);
assert.equal(calls[0].args.path, 'Book One/A.md');
});