From 7bd7574f84de75c3e58ca78f61064376257aa7aa Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 16:17:08 -0400 Subject: [PATCH 1/7] feat(cli): cotal provision-acl + spawn provisions durable ACL when daemon live Add `cotal provision-acl`: a re-runnable privileged pass that writes the durable read-ACL row (`cotal_acl_`) for every persona that already has creds, closing the `cotal mint` + `exec omp` gap where an agent had creds but no ACL row and was @mention-wake-blind. Derives each row to match the creds' baked read scope (drift is fail-loud skipped, never rewritten); a credless persona is skipped (this command never mints). Gate `cotal spawn`'s durable membership on a live delivery daemon (lease probe) instead of hardcoding live-only; grant the provisioner cred the delivery-lease read verb. Mint stays byte-identical. Co-Authored-By: seal --- .../cli/smoke/provision-acl-live.smoke.ts | 298 ++++++++++++++++++ .../cli/src/commands/provision-acl.ts | 64 ++++ implementations/cli/src/commands/spawn.ts | 17 +- implementations/cli/src/index.ts | 9 + implementations/cli/src/lib/acl-provision.ts | 157 +++++++++ package.json | 3 +- packages/core/src/provision.ts | 19 +- 7 files changed, 554 insertions(+), 13 deletions(-) create mode 100644 implementations/cli/smoke/provision-acl-live.smoke.ts create mode 100644 implementations/cli/src/commands/provision-acl.ts create mode 100644 implementations/cli/src/lib/acl-provision.ts diff --git a/implementations/cli/smoke/provision-acl-live.smoke.ts b/implementations/cli/smoke/provision-acl-live.smoke.ts new file mode 100644 index 00000000..35d82062 --- /dev/null +++ b/implementations/cli/smoke/provision-acl-live.smoke.ts @@ -0,0 +1,298 @@ +/** + * Durable read-ACL provisioning — live-broker smoke for `planAclProvision` / `provisionAcls` + * (the routine behind `cotal provision-acl`) and the `cotal spawn` daemon-gate, verified against a + * REAL nats-server (JWT auth + JetStream) on an isolated port. Closes the gap where `cotal mint` + * writes creds but no durable read-ACL row, so an agent is @mention-wake-blind until provisioned. + * + * Behaviors defended (each fails if the write / skip / gate breaks — see the red-green notes in the PR): + * 1. row written + reads back — a persona with an agent file + minted creds gets an ACL row whose + * value an independent provisioner endpoint reads back as its `allowSubscribe`. + * 2. PARITY (defended hardest) — the provisioned row equals the creds' BAKED chat read scope + * (`sub.allow` chat subjects), decoded from the JWT — durable read scope never diverges from live. + * 3. drift is SKIPPED, never rewritten — creds baked [general] but the file declares [general, ops]: + * `planAclProvision` flags `drift`, `provisionAcls` skips it, and a pre-existing row is untouched. + * 4. credless persona is SKIPPED — an agent file with no creds → `result.skipped` ("no creds"), + * never provisioned (this command never mints; that is `cotal mint`'s job). + * 5. default [general] — a file with neither `allowSubscribe` nor `subscribe` → row is [general]. + * 6. idempotent — running `provisionAcls` twice leaves the row + count stable, no throw. + * 7/8. spawn daemon-gate (defended at the routine boundary, NOT a full connector-fork spawn — see the + * note printed at the end): the exact decision `cotal spawn` makes — + * `daemonLive = (await prov.readDeliveryLease(0)) !== undefined` → `provisionAgent({durableMembership})` + * — driven by a REAL delivery lease. Lease present ⇒ ACL row for the spawned id; absent ⇒ no row. + * + * Needs the bundled `nats-server` — resolved via the CLI's own `resolveNatsServer()` (PATH first, then + * the bundled platform package), so it runs without an operator-installed server. Kills ONLY the PID it + * spawns (never pkill). Run: pnpm smoke:provision-acl:live + */ +import { randomUUID } from "node:crypto"; +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createSpaceAuth, + serverConfig, + setupSpaceStreams, + isReachable, + mintCreds, + newIdentity, + provisionAgent, + chatSubject, + CotalEndpoint, +} from "@cotal-ai/core"; +import { authDir } from "@cotal-ai/workspace"; +import { planAclProvision, provisionAcls } from "../src/lib/acl-provision.js"; +import { personasDir } from "../src/lib/personas.js"; +import { resolveNatsServer } from "../src/lib/nats-bin.js"; + +const PORT = 20000 + Math.floor(Math.random() * 40000); +const SERVERS = `nats://127.0.0.1:${PORT}`; +const space = `provacl-${randomUUID().slice(0, 8)}`; + +function sleep(ms: number): Promise { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, ms); + return promise; +} +function awaitExit(proc: ChildProcess, timeoutMs = 3000): Promise { + const { promise, resolve } = Promise.withResolvers(); + if (proc.exitCode !== null || proc.signalCode !== null) { + resolve(); + return promise; + } + proc.once("exit", () => resolve()); + setTimeout(resolve, timeoutMs); + return promise; +} +const eq = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); + +let pass = 0, + fail = 0; +const check = (name: string, cond: boolean, extra?: unknown) => { + if (cond) { + pass++; + console.log(` ✓ ${name}`); + } else { + fail++; + console.log(` ✗ FAIL: ${name}`, extra ?? ""); + } +}; + +/** The chat `sub.allow` entries a creds JWT was minted with — replicated from `acl-provision.ts` + * `credChatSubAllow` (not exported), so #2 can prove the row matches the creds' broker-enforced scope. */ +function credChatSubAllow(creds: string): string[] { + const m = creds.match(/BEGIN NATS USER JWT-----\s*([\s\S]*?)\s*------END NATS USER JWT/); + const payload = m?.[1].trim().split(".")[1]; + if (!payload) return []; + const claim = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + nats?: { sub?: { allow?: string[] } }; + }; + const prefix = chatSubject(space, "*", "").slice(0, -1); // cotal..chat.*. (channel stripped) + return (claim.nats?.sub?.allow ?? []).filter((s) => s.startsWith(prefix)).sort(); +} + +const auth = await createSpaceAuth(space); +const roots: string[] = []; +/** A fresh, isolated persona catalog + creds dir (its own `/.cotal/{agents,auth/creds}`). Each + * behavior gets its own root so `planAclProvision` (which walks the WHOLE catalog) sees only its + * personas; all share the one space/broker (rows are id-keyed, so no cross-behavior collision). */ +function freshRoot(label: string): string { + const root = mkdtempSync(join(tmpdir(), `cotal-provacl-${label}-`)); + mkdirSync(personasDir(root), { recursive: true }); + mkdirSync(join(authDir(root), "creds"), { recursive: true }); + roots.push(root); + return root; +} +function writeAgent(root: string, name: string, fm: string): void { + writeFileSync(join(personasDir(root), `${name}.md`), `---\nname: ${name}\n${fm}\n---\nYou are ${name}.\n`); +} +/** Mint agent creds from the SAME derivation `cotal mint` uses and drop them at the root's creds path, + * returning the stable id + the creds string (needed to decode the baked read scope for parity). */ +async function mintPersona(root: string, name: string, allowSubscribe?: string[]): Promise<{ id: string; creds: string }> { + const identity = newIdentity(); + const creds = await mintCreds(auth, identity, "agent", { allowSubscribe }); + writeFileSync(join(authDir(root), "creds", `${name}.creds`), creds); + return { id: identity.id, creds }; +} + +const dir = mkdtempSync(join(tmpdir(), "cotal-provacl-srv-")); +writeFileSync(join(dir, "server.conf"), serverConfig(auth, { port: PORT, storeDir: join(dir, "js") })); +const { bin: natsBin } = await resolveNatsServer(); +const srv = spawn(natsBin, ["-c", join(dir, "server.conf")], { stdio: "ignore" }); + +let reader: CotalEndpoint | undefined; +let deliveryEp: CotalEndpoint | undefined; +try { + let up = false; + for (let i = 0; i < 50; i++) { + if (await isReachable(SERVERS)) { + up = true; + break; + } + await sleep(200); + } + if (!up) throw new Error(`auth nats-server did not come up on ${PORT}`); + await setupSpaceStreams({ servers: SERVERS, space, creds: await mintCreds(auth, newIdentity(), "provisioner") }); + + // Independent provisioner endpoint — reads rows back (the daemon's-eye view, separate from the + // routine's own short-lived endpoint), pre-seeds the drift row, and drives the spawn-gate decision. + reader = new CotalEndpoint({ + space, + servers: SERVERS, + creds: await mintCreds(auth, newIdentity(), "provisioner"), + channels: [], + consume: false, + registerPresence: false, + watchPresence: false, + watchChannels: false, + card: { name: "reader", role: "provisioner", kind: "endpoint" }, + }); + reader.on("error", () => {}); + await reader.start(); + + const run = (root: string) => provisionAcls({ root, space, server: SERVERS, auth }); + + // ── 1. row written + reads back ───────────────────────────────────────────────────────────── + const rAlpha = freshRoot("alpha"); + writeAgent(rAlpha, "alpha", "allowSubscribe: [general, ops]"); + const alpha = await mintPersona(rAlpha, "alpha", ["general", "ops"]); + const r1 = await run(rAlpha); + check("[#1 row-written] alpha appears in result.provisioned", r1.provisioned.some((p) => p.name === "alpha"), r1); + const alphaRow = await reader.aclForOwner(alpha.id); + check("[#1 row-written] alpha's ACL row reads back as [general, ops]", eq(alphaRow, ["general", "ops"]), alphaRow); + + // ── 2. PARITY: provisioned row == creds' baked chat read scope ────────────────────────────── + const rBravo = freshRoot("bravo"); + writeAgent(rBravo, "bravo", "allowSubscribe: [general, ops]"); + const bravo = await mintPersona(rBravo, "bravo", ["general", "ops"]); + await run(rBravo); + const bravoRow = (await reader.aclForOwner(bravo.id)) ?? []; + check("[#2 parity] bravo row == [general, ops]", eq(bravoRow, ["general", "ops"]), bravoRow); + const rowSubjects = bravoRow.map((ch) => chatSubject(space, "*", ch)).sort(); + const bakedSubjects = credChatSubAllow(bravo.creds); + check( + "[#2 parity] row channels render to the creds' BAKED chat sub.allow subjects", + rowSubjects.length > 0 && eq(rowSubjects, bakedSubjects), + { rowSubjects, bakedSubjects }, + ); + + // ── 3. drift is SKIPPED, never rewritten ──────────────────────────────────────────────────── + // creds baked [general] but the file declares [general, ops] → mismatch. Pre-seed the correct row. + const rCharlie = freshRoot("charlie"); + writeAgent(rCharlie, "charlie", "allowSubscribe: [general, ops]"); + const charlie = await mintPersona(rCharlie, "charlie", ["general"]); + await reader.commitAcl(charlie.id, ["general"]); // the existing, correct-for-creds row + const plan = planAclProvision(rCharlie, space); + const charlieEntry = plan.find((e) => e.name === "charlie"); + check("[#3 drift] planAclProvision flags charlie's drift", Boolean(charlieEntry?.drift), charlieEntry); + const r3 = await run(rCharlie); + check( + "[#3 drift] charlie is in result.skipped, NOT result.provisioned", + r3.skipped.some((s) => s.name === "charlie") && !r3.provisioned.some((p) => p.name === "charlie"), + r3, + ); + const charlieRow = await reader.aclForOwner(charlie.id); + check("[#3 drift] the existing charlie row is NOT overwritten (still [general])", eq(charlieRow, ["general"]), charlieRow); + + // ── 4. credless persona is SKIPPED (this command never mints) ─────────────────────────────── + const rFox = freshRoot("foxtrot"); + writeAgent(rFox, "foxtrot", "allowSubscribe: [general]"); // no creds file written + const r4 = await run(rFox); + const foxSkip = r4.skipped.find((s) => s.name === "foxtrot"); + check( + "[#4 credless] foxtrot skipped with a 'no creds' reason, never provisioned", + /no creds/i.test(foxSkip?.reason ?? "") && !r4.provisioned.some((p) => p.name === "foxtrot"), + r4, + ); + + // ── 5. default [general] when neither read field is declared ──────────────────────────────── + const rDelta = freshRoot("delta"); + writeAgent(rDelta, "delta", "role: worker"); // no subscribe / no allowSubscribe + const delta = await mintPersona(rDelta, "delta"); // minted with the same default → no drift + await run(rDelta); + const deltaRow = await reader.aclForOwner(delta.id); + check("[#5 default] delta with no read fields → row is [general]", eq(deltaRow, ["general"]), deltaRow); + + // ── 6. idempotent: two runs leave row + count stable, no throw ─────────────────────────────── + const rEcho = freshRoot("echo"); + writeAgent(rEcho, "echo", "allowSubscribe: [general, ops]"); + const echo = await mintPersona(rEcho, "echo", ["general", "ops"]); + const e1 = await run(rEcho); + const e2 = await run(rEcho); // rewrites the same value (core commitAcl is an atomic CAS put) + const echoRow = await reader.aclForOwner(echo.id); + check( + "[#6 idempotent] second run: rowCount + provisioned stable, row unchanged", + e2.rowCount === e1.rowCount && e2.provisioned.length === 1 && eq(echoRow, ["general", "ops"]), + { e1: e1.rowCount, e2: e2.rowCount, echoRow }, + ); + + // ── 7/8. spawn daemon-gate — the exact `cotal spawn` decision, at the routine boundary ─────── + // Boundary defense (NOT a connector-fork spawn): replicate spawn.ts's + // `daemonLive = (await prov.readDeliveryLease(0)) !== undefined` → provisionAgent({durableMembership}) + // driven by a REAL lease, and assert the OBSERVABLE consequence (row present vs absent). This also + // exercises the provisioner's new `STREAM.MSG.GET.KV_` read grant (the read would 403 without it). + + // #8 first — before any lease exists: absent lease ⇒ live-only ⇒ NO row. + const noDaemonId = newIdentity(); + const daemonLiveBefore = (await reader.readDeliveryLease(0)) !== undefined; + await provisionAgent(reader, auth, noDaemonId, { allowSubscribe: ["general"], durableMembership: daemonLiveBefore }); + const noDaemonRow = await reader.aclForOwner(noDaemonId.id); + check( + "[#8 gate] no delivery lease ⇒ daemonLive false ⇒ NO ACL row for the spawned id", + daemonLiveBefore === false && noDaemonRow === undefined, + { daemonLiveBefore, noDaemonRow }, + ); + + // Bring a real delivery daemon's lease up (CAS create under the scoped `delivery` cred). + deliveryEp = new CotalEndpoint({ + space, + servers: SERVERS, + creds: await mintCreds(auth, newIdentity(), "delivery"), + channels: [], + consume: false, + registerPresence: false, + watchPresence: false, + card: { name: "delivery", role: "delivery", kind: "endpoint" }, + }); + deliveryEp.on("error", () => {}); + await deliveryEp.start(); + await deliveryEp.acquireDeliveryLease(0); + + // #7 — lease present ⇒ durable membership ⇒ ACL row for the spawned id. + const daemonId = newIdentity(); + const daemonLiveAfter = (await reader.readDeliveryLease(0)) !== undefined; + await provisionAgent(reader, auth, daemonId, { allowSubscribe: ["general", "ops"], durableMembership: daemonLiveAfter }); + const daemonRow = await reader.aclForOwner(daemonId.id); + check( + "[#7 gate] delivery lease present ⇒ daemonLive true ⇒ ACL row for the spawned id", + daemonLiveAfter === true && eq(daemonRow, ["general", "ops"]), + { daemonLiveAfter, daemonRow }, + ); + + console.log( + `\nNote: #7/#8 defend the spawn daemon-gate at the ROUTINE BOUNDARY (readDeliveryLease→durableMembership→row),` + + ` driven by a real lease — not a full connector-fork \`cotal spawn\` (out of harness scope).`, + ); + console.log(`\nPROVISION-ACL SMOKE ${fail === 0 ? "OK ✅" : "FAILED ❌"} (${pass} passed, ${fail} failed)`); + if (fail) process.exitCode = 1; +} catch (e) { + fail++; + console.error(" ✗ scenario threw:", (e as Error).message); + process.exitCode = 1; +} finally { + try { + await reader?.stop(); + } catch { + /* ignore */ + } + try { + await deliveryEp?.stop(); + } catch { + /* ignore */ + } + srv.kill("SIGKILL"); + await awaitExit(srv); + rmSync(dir, { recursive: true, force: true }); + for (const r of roots) rmSync(r, { recursive: true, force: true }); +} +process.exit(process.exitCode ?? (fail ? 1 : 0)); // force-exit: lingering endpoint reconnect timers keep the loop alive diff --git a/implementations/cli/src/commands/provision-acl.ts b/implementations/cli/src/commands/provision-acl.ts new file mode 100644 index 00000000..23fa2998 --- /dev/null +++ b/implementations/cli/src/commands/provision-acl.ts @@ -0,0 +1,64 @@ +import { parseArgs } from "node:util"; +import { planAclProvision, provisionAcls } from "../lib/acl-provision.js"; +import { resolveTargetOrExit } from "../lib/connect.js"; +import { cotalRoot } from "../lib/paths.js"; +import { c } from "../ui.js"; + +/** + * `cotal provision-acl` — write the durable read-ACL row (`cotal_acl_`) for every persona in the + * catalog, so the standalone delivery daemon authorizes their durable @mention-wake deliveries. + * + * The gap this closes: `cotal mint` writes creds but is offline — it never touches the mesh — so an + * agent brought up via `cotal mint` + `exec omp` (no manager/`cotal spawn` in the loop) has no ACL row + * and is @mention-wake-blind until something provisions it. This command is that something: a + * re-runnable, privileged pass that derives each agent's read ACL to match exactly what its creds were + * minted with (so durable read scope never diverges from live), and commits the row. + * + * Idempotent (core `commitAcl` is an atomic CAS put). `--dry-run` prints the plan without connecting. + * A credless persona is SKIPPED (a row is keyed by agent id, which exists only once creds are minted) — + * this command never mints (that's `cotal mint`; mint-if-absent is the still-open mint-strategy fork). A + * persona whose creds' baked read scope diverges from its file ACL is fail-loud skipped with a re-mint + * hint — never silently rewritten. + */ +export async function provisionAcl(argv: string[]): Promise { + const { values } = parseArgs({ + args: argv, + options: { + server: { type: "string" }, + space: { type: "string" }, + "dry-run": { type: "boolean" }, + }, + }); + const root = cotalRoot(); + + // --dry-run: OFFLINE — show the plan (what would be provisioned/skipped) without a connection. + if (values["dry-run"]) { + const space = values.space ?? "main"; + const plan = planAclProvision(root, space); + console.log(c.bold(`provision-acl (dry run) — space "${space}" — ${plan.length} personas`)); + for (const e of plan) { + if (e.error) { console.log(` ${c.red("skip")} ${e.name.padEnd(16)} persona parse error: ${e.error}`); continue; } + if (e.drift) { console.log(` ${c.red("skip")} ${e.name.padEnd(16)} ${e.drift}`); continue; } + const scope = `[${e.allowSubscribe.join(", ")}]`; + if (!e.hasCreds) console.log(` ${c.yellow("skip")} ${e.name.padEnd(16)} ${scope} (no creds — run \`cotal mint\` first)`); + else console.log(` ${c.green("acl ")} ${e.name.padEnd(16)} ${scope}`); + } + console.log(c.dim("dry run — nothing written. Re-run without --dry-run to commit.")); + return; + } + + const target = await resolveTargetOrExit({ server: values.server, space: values.space }); + if (!target.auth) { + console.error(c.red("provision-acl needs an auth-mode mesh (it mints a privileged provisioner cred) — this target is open/off-registry.")); + process.exit(1); + } + const result = await provisionAcls({ root, space: target.space, server: target.server, auth: target.auth }); + for (const p of result.provisioned) + console.log(` ${c.green("✓")} ${p.name.padEnd(16)} [${p.allowSubscribe.join(", ")}]`); + for (const s of result.skipped) console.log(` ${c.yellow("skip")} ${s.name.padEnd(16)} ${s.reason}`); + console.log( + c.bold(`provisioned ${result.provisioned.length} ACL row(s)`) + + (result.skipped.length ? `, skipped ${result.skipped.length}` : "") + + ` — space "${target.space}"`, + ); +} diff --git a/implementations/cli/src/commands/spawn.ts b/implementations/cli/src/commands/spawn.ts index 1566363c..224ed604 100644 --- a/implementations/cli/src/commands/spawn.ts +++ b/implementations/cli/src/commands/spawn.ts @@ -241,19 +241,24 @@ export async function spawn(argv: string[]): Promise { }); prov.on("error", (e: Error) => console.error(`! provisioner: ${e.message}`)); await prov.start(); - // Direct foreground spawn is LIVE-ONLY: this short-lived provisioner is not a managing Plane-3 host, - // and no long-lived manager knows this agent (it's in no manager's `agents` ledger), so a durable - // boot membership could be neither authorized for reader delivery nor leaved via self-service. Skip - // it — the agent reads live via its core-sub; a durable backstop requires spawning under a manager - // (`cotal start` / `cotal up`). + // Durable membership is gated on a LIVE delivery daemon, not on being manager-spawned. The durable + // reader is the standalone delivery daemon, which re-authorizes every entry from the ACL registry + // (not any manager ledger), and self-service leave rides `ctl.delivery.` to that daemon — so a + // foreground-spawned agent CAN get a durable backstop as long as a daemon is serving. Probe the + // shard-0 delivery lease: present ⇒ provision the ACL row (durable @mention-wake works while the + // agent is busy/offline); absent ⇒ no daemon, so stay live-only (an un-authorizable row would just + // accrete). The boot self-join's reconcile loop tolerates responder-timing, so lease-present is the + // right signal. (`cotal join`'s bare console stays intentionally live-only — it never provisions.) + const daemonLive = (await prov.readDeliveryLease(0)) !== undefined; const creds = await provisionAgent(prov, auth, identity, { subscribe, allowSubscribe, allowPublish, role, capabilities: def.capabilities, - durableMembership: false, + durableMembership: daemonLive, }); + if (daemonLive) console.error(`durable delivery provisioned for ${name} (delivery daemon live)`); await prov.stop(); credsPath = join(authDir(target.root), "creds", `${name}.creds`); mkSecretDir(dirname(credsPath)); // harden the creds dir before the cred lands diff --git a/implementations/cli/src/index.ts b/implementations/cli/src/index.ts index 8daa9c24..207ca366 100644 --- a/implementations/cli/src/index.ts +++ b/implementations/cli/src/index.ts @@ -12,6 +12,7 @@ import { spawn, spawnComplete } from "./commands/spawn.js"; import { personas, personasComplete } from "./commands/personas.js"; import { completion, completionComplete, complete } from "./commands/completion.js"; import { mint } from "./commands/mint.js"; +import { provisionAcl } from "./commands/provision-acl.js"; import { channels } from "./commands/channels.js"; import { history } from "./commands/history.js"; import { feedback } from "./commands/feedback.js"; @@ -147,6 +148,14 @@ const baseCommands: Command[] = [ "mint a creds file for a space (auth mode) — mint --profile [--out ]; --signer emits a stripped account-signing file (no operator key) for a containerized manager", run: mint, }, + { + kind: "command", + name: "provision-acl", + group: "Mesh", + summary: + "write the durable read-ACL row for every persona so the delivery daemon authorizes @mention-wake — provision-acl [--dry-run] [--mint-missing] [--space ]; closes the `cotal mint` + `exec omp` gap", + run: provisionAcl, + }, { kind: "command", name: "topology", diff --git a/implementations/cli/src/lib/acl-provision.ts b/implementations/cli/src/lib/acl-provision.ts new file mode 100644 index 00000000..3d033f26 --- /dev/null +++ b/implementations/cli/src/lib/acl-provision.ts @@ -0,0 +1,157 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + chatSubject, + CotalEndpoint, + idFromCreds, + mintCreds, + newIdentity, + type SpaceAuth, +} from "@cotal-ai/core"; +import { authDir } from "@cotal-ai/workspace"; +import { listPersonas } from "./personas.js"; + +/** + * Durable read-ACL provisioning — the durable form of the out-of-band backfill. + * + * The delivery daemon authorizes an agent's durable @mention-wake deliveries against its row in the + * `cotal_acl_` registry (absent ⇒ DEFER, never deliver). `cotal mint` writes creds but not + * that row (it is offline), so an agent launched via `cotal mint` + `exec omp` is @mention-wake-blind + * until something provisions the row. This routine walks the persona catalog and commits a row for + * every agent, deriving the read ACL to match exactly what its creds were minted with — so durable + * read scope never diverges from the live (sub.allow) read scope. + * + * Idempotent (core `commitAcl` is an atomic CAS put — re-running rewrites the same value). Runs under + * a privileged provisioner cred; agents never write their own ACL. This is the option-agnostic + * correctness mechanism; whether it is auto-invoked at `cotal up` or a mint flag drives it is the + * open mint-strategy fork (design PR: durable-delivery-acl-provisioning). + */ + +/** The read ACL an agent is minted with — replicated verbatim from the mint chokepoint so a + * provisioned row equals the creds' baked `sub.allow`. Two steps, each matching one line: + * `cotal mint` picks `allowSubscribe ?? subscribe` (nullish — an explicit `[]` is KEPT, not + * replaced by `subscribe`; mint.ts:84), then `permissionsFor` maps an empty/absent list to + * `["general"]` (`?.length ? it : ["general"]`; provision.ts:411). Kept in lockstep with both. */ +function agentReadAcl(def: { allowSubscribe?: string[]; subscribe?: string[] } | undefined): string[] { + const declared = def?.allowSubscribe ?? def?.subscribe; // mint.ts:84 — nullish, NOT length + return declared?.length ? declared : ["general"]; // permissionsFor :411 +} + +/** The chat `sub.allow` entries a creds JWT was minted with — the authoritative, broker-enforced read + * grant. Used to cross-check that a provisioned ACL matches the creds (drift ⇒ a re-mint is needed). */ +function credChatSubAllow(creds: string, space: string): string[] { + const m = creds.match(/BEGIN NATS USER JWT-----\s*([\s\S]*?)\s*------END NATS USER JWT/); + const payload = m?.[1].trim().split(".")[1]; + if (!payload) return []; + const claim = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + nats?: { sub?: { allow?: string[] } }; + }; + const prefix = `${chatSubject(space, "*", "").slice(0, -1)}`; // cotal..chat.*. (channel stripped) + return (claim.nats?.sub?.allow ?? []).filter((s) => s.startsWith(prefix)).sort(); +} + +export interface AclProvisionPlanEntry { + name: string; + /** Present once creds exist (derived from the creds); undefined for a persona with no creds yet. */ + id?: string; + allowSubscribe: string[]; + /** True when the agent already has creds on disk; false when they must be minted. */ + hasCreds: boolean; + /** Set when the creds' baked read scope diverges from the file-derived ACL (a re-mint is required). */ + drift?: string; + /** Set when the persona file failed to parse. */ + error?: string; +} + +/** Build the provisioning plan from the persona catalog — pure/offline (reads files, no mesh). */ +export function planAclProvision(root: string, space: string): AclProvisionPlanEntry[] { + const credsDir = join(authDir(root), "creds"); + const plan: AclProvisionPlanEntry[] = []; + for (const p of listPersonas(root)) { + if (p.error) { + plan.push({ name: p.name, allowSubscribe: [], hasCreds: false, error: p.error }); + continue; + } + const allowSubscribe = agentReadAcl(p.def); + const credsPath = join(credsDir, `${p.name}.creds`); + if (!existsSync(credsPath)) { + plan.push({ name: p.name, allowSubscribe, hasCreds: false }); + continue; + } + const creds = readFileSync(credsPath, "utf8"); + const id = idFromCreds(creds); + // Parity: the file-derived ACL must render to the same chat sub.allow the creds carry, or the + // durable read scope would diverge from the live one. Compare as sorted sets. + const want = allowSubscribe.map((ch) => chatSubject(space, "*", ch)).sort(); + const have = credChatSubAllow(creds, space); + const drift = + JSON.stringify(want) === JSON.stringify(have) + ? undefined + : `creds read scope [${have.join(", ")}] != file ACL [${want.join(", ")}] — re-mint ${p.name}`; + plan.push({ name: p.name, id, allowSubscribe, hasCreds: true, drift }); + } + return plan; +} + +export interface AclProvisionResult { + provisioned: { name: string; id: string; allowSubscribe: string[] }[]; + skipped: { name: string; reason: string }[]; + rowCount: number; +} + +/** + * Provision (commit) an ACL row for every persona that already has creds. A credless persona is + * SKIPPED (an ACL row is keyed by agent id, which exists only once creds are minted) — this command + * never mints: minting is `cotal mint`'s job, and mint-if-absent is the still-open mint-strategy fork. + * A drift entry (creds read scope != file ACL) is fail-loud skipped, never silently rewritten. + */ +export async function provisionAcls(opts: { + root: string; + space: string; + server: string; + auth: SpaceAuth; +}): Promise { + const plan = planAclProvision(opts.root, opts.space); + const ep = new CotalEndpoint({ + space: opts.space, + servers: opts.server, + creds: await mintCreds(opts.auth, newIdentity(), "provisioner"), + channels: [], + consume: false, + registerPresence: false, + watchPresence: false, + watchChannels: false, + card: { name: "acl-provisioner", role: "provisioner", kind: "endpoint" }, + }); + ep.on("error", () => {}); // JS API errors surface on the awaited call; don't crash the process + await ep.start(); + const result: AclProvisionResult = { provisioned: [], skipped: [], rowCount: 0 }; + try { + for (const e of plan) { + if (e.error) { + result.skipped.push({ name: e.name, reason: `persona parse error: ${e.error}` }); + continue; + } + if (e.drift) { + result.skipped.push({ name: e.name, reason: e.drift }); + continue; + } + if (!e.hasCreds) { + result.skipped.push({ name: e.name, reason: "no creds (run `cotal mint` first)" }); + continue; + } + const id = e.id; + await ep.commitAcl(id as string, e.allowSubscribe); + // Read-back through the public accessor confirms the row landed (and is what the daemon's reader + // will see) — a real verification, not just a key tally. + const back = await ep.aclForOwner(id as string); + if (JSON.stringify(back) !== JSON.stringify(e.allowSubscribe)) + throw new Error(`ACL write for ${e.name} did not read back: wrote ${JSON.stringify(e.allowSubscribe)}, read ${JSON.stringify(back)}`); + result.provisioned.push({ name: e.name, id: id as string, allowSubscribe: e.allowSubscribe }); + } + result.rowCount = result.provisioned.length; + } finally { + await ep.stop(); + } + return result; +} diff --git a/package.json b/package.json index 30071edc..b8270c7d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "gen:schema": "node scripts/generate-cotal-schema.mjs", "test": "pnpm -r --if-present test", "check": "pnpm typecheck && pnpm test && pnpm smoke:view && pnpm smoke:spawn-from-anywhere && pnpm smoke:spawn-from-anywhere:live && pnpm smoke:connect && pnpm smoke:core-boundary && pnpm smoke:preflight && pnpm smoke:ci", - "smoke:ci": "pnpm smoke:read-acl:auth && pnpm smoke:sub-acl:auth && pnpm smoke:self-serve-join:auth && pnpm smoke:control-auth && pnpm smoke:manager-split && pnpm smoke:deprovision && pnpm smoke:control-reply-bound && pnpm smoke:channels:auth && pnpm smoke:e2e:acl && pnpm smoke:plane3:auth && pnpm smoke:delivery-lease:auth && pnpm smoke:delivery-leave-tombstone:auth && pnpm smoke:delivery-reconnect:auth && pnpm smoke:delivery-cred:auth && pnpm smoke:delivery-reply-injection:auth && pnpm smoke:membership:auth && pnpm smoke:membership-feed-confinement:auth && pnpm smoke:wildcard-backfill && pnpm smoke:opencode && pnpm smoke:opencode-coop && pnpm smoke:opencode-transcript && pnpm smoke:transcript-grant && pnpm smoke:persona-acl", + "smoke:ci": "pnpm smoke:read-acl:auth && pnpm smoke:sub-acl:auth && pnpm smoke:self-serve-join:auth && pnpm smoke:control-auth && pnpm smoke:manager-split && pnpm smoke:deprovision && pnpm smoke:control-reply-bound && pnpm smoke:channels:auth && pnpm smoke:e2e:acl && pnpm smoke:plane3:auth && pnpm smoke:delivery-lease:auth && pnpm smoke:delivery-leave-tombstone:auth && pnpm smoke:delivery-reconnect:auth && pnpm smoke:delivery-cred:auth && pnpm smoke:delivery-reply-injection:auth && pnpm smoke:membership:auth && pnpm smoke:membership-feed-confinement:auth && pnpm smoke:wildcard-backfill && pnpm smoke:opencode && pnpm smoke:opencode-coop && pnpm smoke:opencode-transcript && pnpm smoke:transcript-grant && pnpm smoke:persona-acl && pnpm smoke:provision-acl:live", "clean:dry": "git clean -ndX -- node_modules .pnpm-store packages extensions implementations examples remotion", "clean": "git clean -fdX -- node_modules .pnpm-store packages extensions implementations examples remotion", "cotal": "tsx bin/cotal.ts", @@ -85,6 +85,7 @@ "smoke:manager-singleton:live": "tsx implementations/cli/smoke/manager-singleton-live.smoke.ts", "smoke:connect": "tsx implementations/cli/smoke/connect.smoke.ts", "smoke:web-seed:live": "tsx implementations/cli/smoke/web-seed-live.smoke.ts", + "smoke:provision-acl:live": "tsx implementations/cli/smoke/provision-acl-live.smoke.ts", "smoke:delivery-cred:auth": "tsx packages/core/smoke/delivery-cred-confinement.smoke.ts", "smoke:delivery-reply-injection:auth": "tsx packages/core/smoke/delivery-reply-injection.smoke.ts", "smoke:delivery-shards-reject": "tsx implementations/delivery/smoke/delivery-shards-reject.smoke.ts", diff --git a/packages/core/src/provision.ts b/packages/core/src/provision.ts index 1ee931d2..2b45c337 100644 --- a/packages/core/src/provision.ts +++ b/packages/core/src/provision.ts @@ -211,11 +211,12 @@ export interface ProvisionOpts extends MintOpts { subscribe?: string[]; /** Record this agent's read ACL so it can participate in durable delivery (default true). A durable * backstop needs the agent's read ACL in the registry — the server-side delivery daemon re-authorizes - * every durable entry against it — written here at provision. Set FALSE for a LIVE-ONLY launcher - * (e.g. a direct foreground `cotal spawn` with no durable intent): no ACL row is written, so the daemon - * refuses to authorize a durable backstop and the agent stays live-only. Boot durable MEMBERSHIP itself - * is not written here — the agent self-joins its durable channels via the daemon's `ctl.delivery` op at - * connect. */ + * every durable entry against it — written here at provision. Set FALSE for a LIVE-ONLY launcher: no + * ACL row is written, so the daemon refuses to authorize a durable backstop and the agent stays + * live-only. Callers gate this on whether a durable backstop is actually reachable — e.g. `cotal spawn` + * passes the result of a delivery-lease probe (daemon live ⇒ true), and `cotal join`'s bare console + * passes false. Boot durable MEMBERSHIP itself is not written here — the agent self-joins its durable + * channels via the daemon's `ctl.delivery` op at connect. */ durableMembership?: boolean; } @@ -242,7 +243,8 @@ export interface DurableProvisioner { * mint its scoped creds. Live delivery is the agent's own core subscription — there is no per-instance * chat durable. Boot durable MEMBERSHIP is not written here: the agent self-joins its durable channels * via the server-side delivery daemon's `ctl.delivery` op at connect. A live-only launcher - * (`durableMembership:false`, e.g. direct `cotal spawn`) gets no ACL row and stays live-only. */ + * (`durableMembership:false`, e.g. `cotal join`'s bare console, or `cotal spawn` when no delivery + * daemon is live) gets no ACL row and stays live-only. */ export async function provisionAgent( provisioner: DurableProvisioner, auth: SpaceAuth, @@ -900,6 +902,11 @@ function provisionerPermissions(space: string, id: string): Record`, // keyed get: `.>` (the key rides the subject) `$JS.API.STREAM.MSG.GET.KV_${channelBucket(space)}`, `$JS.API.DIRECT.GET.KV_${channelBucket(space)}.>`, // keyed get: `.>` (the key rides the subject) + // Delivery lease/readiness: READ-ONLY (`kv.get` ⇒ STREAM.MSG.GET) — `cotal spawn` probes it to + // decide durable-vs-live-only (provision the ACL only when a delivery daemon is live). STREAM.INFO + // is already granted in `streamSetup` (the bucket is in `buckets`); no WRITE (only the `delivery` + // cred writes the lease). Mirrors the agent's own Component-6 lease read (permissionsFor :474-475). + `$JS.API.STREAM.MSG.GET.KV_${deliveryBucket(space)}`, ], }, // Replies only: every stream/consumer/KV-create PubAck and JS API response lands on the per-id inbox. From ade0c9f4974caa080d4d2a7adc1541503d98b824 Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 17:18:57 -0400 Subject: [PATCH 2/7] fix(cli): harden acl-provision per PR #4 review (round 1) - planAclProvision: isolate a bad/unreadable creds file as a per-persona error entry instead of throwing and aborting the whole catalog pass (a later valid persona would otherwise be left @mention-wake-blind). - provisionAcls: compare read-back ACL as a sorted set, not order-sensitive. - provision-acl: scan target.root (the resolved mesh) not cwd, so --space / out-of-checkout invocations provision the right catalog. - index: drop stale [--mint-missing] from the provision-acl summary (the command never parsed it). - smoke: replace Promise.withResolvers (Node 20 compat, repo engines >=20), move server setup inside try/finally, add nats-server spawn error listener. Co-Authored-By: seal --- .../cli/smoke/provision-acl-live.smoke.ts | 39 ++++++++++--------- .../cli/src/commands/provision-acl.ts | 6 ++- implementations/cli/src/index.ts | 2 +- implementations/cli/src/lib/acl-provision.ts | 31 +++++++++------ 4 files changed, 45 insertions(+), 33 deletions(-) diff --git a/implementations/cli/smoke/provision-acl-live.smoke.ts b/implementations/cli/smoke/provision-acl-live.smoke.ts index 35d82062..ca794f44 100644 --- a/implementations/cli/smoke/provision-acl-live.smoke.ts +++ b/implementations/cli/smoke/provision-acl-live.smoke.ts @@ -50,19 +50,14 @@ const SERVERS = `nats://127.0.0.1:${PORT}`; const space = `provacl-${randomUUID().slice(0, 8)}`; function sleep(ms: number): Promise { - const { promise, resolve } = Promise.withResolvers(); - setTimeout(resolve, ms); - return promise; + return new Promise((resolve) => setTimeout(resolve, ms)); } function awaitExit(proc: ChildProcess, timeoutMs = 3000): Promise { - const { promise, resolve } = Promise.withResolvers(); - if (proc.exitCode !== null || proc.signalCode !== null) { - resolve(); - return promise; - } - proc.once("exit", () => resolve()); - setTimeout(resolve, timeoutMs); - return promise; + return new Promise((resolve) => { + if (proc.exitCode !== null || proc.signalCode !== null) return resolve(); + proc.once("exit", () => resolve()); + setTimeout(resolve, timeoutMs); + }); } const eq = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); @@ -115,14 +110,18 @@ async function mintPersona(root: string, name: string, allowSubscribe?: string[] return { id: identity.id, creds }; } -const dir = mkdtempSync(join(tmpdir(), "cotal-provacl-srv-")); -writeFileSync(join(dir, "server.conf"), serverConfig(auth, { port: PORT, storeDir: join(dir, "js") })); -const { bin: natsBin } = await resolveNatsServer(); -const srv = spawn(natsBin, ["-c", join(dir, "server.conf")], { stdio: "ignore" }); - let reader: CotalEndpoint | undefined; let deliveryEp: CotalEndpoint | undefined; +let dir: string | undefined; +let srv: ChildProcess | undefined; try { + // Server setup lives INSIDE the try so a throw here (e.g. resolveNatsServer can't find the binary) + // still hits the finally — no leaked temp dir, no dangling child. + dir = mkdtempSync(join(tmpdir(), "cotal-provacl-srv-")); + writeFileSync(join(dir, "server.conf"), serverConfig(auth, { port: PORT, storeDir: join(dir, "js") })); + const { bin: natsBin } = await resolveNatsServer(); + srv = spawn(natsBin, ["-c", join(dir, "server.conf")], { stdio: "ignore" }); + srv.on("error", (e) => console.error(" ! nats-server spawn error:", e.message)); // never let an async spawn error go unhandled (would crash the process before cleanup) let up = false; for (let i = 0; i < 50; i++) { if (await isReachable(SERVERS)) { @@ -290,9 +289,11 @@ try { } catch { /* ignore */ } - srv.kill("SIGKILL"); - await awaitExit(srv); - rmSync(dir, { recursive: true, force: true }); + if (srv) { + srv.kill("SIGKILL"); + await awaitExit(srv); + } + if (dir) rmSync(dir, { recursive: true, force: true }); for (const r of roots) rmSync(r, { recursive: true, force: true }); } process.exit(process.exitCode ?? (fail ? 1 : 0)); // force-exit: lingering endpoint reconnect timers keep the loop alive diff --git a/implementations/cli/src/commands/provision-acl.ts b/implementations/cli/src/commands/provision-acl.ts index 23fa2998..b9b83eeb 100644 --- a/implementations/cli/src/commands/provision-acl.ts +++ b/implementations/cli/src/commands/provision-acl.ts @@ -52,7 +52,11 @@ export async function provisionAcl(argv: string[]): Promise { console.error(c.red("provision-acl needs an auth-mode mesh (it mints a privileged provisioner cred) — this target is open/off-registry.")); process.exit(1); } - const result = await provisionAcls({ root, space: target.space, server: target.server, auth: target.auth }); + // Scan the RESOLVED mesh's catalog, not the cwd's: `--space`/out-of-checkout invocations resolve a + // registered mesh whose root differs from cwd. `target.root` is set for a registry-resolved auth mesh + // (guaranteed here — the `!target.auth` guard above already exited an off-registry target); fall back + // to the cwd root defensively. + const result = await provisionAcls({ root: target.root ?? root, space: target.space, server: target.server, auth: target.auth }); for (const p of result.provisioned) console.log(` ${c.green("✓")} ${p.name.padEnd(16)} [${p.allowSubscribe.join(", ")}]`); for (const s of result.skipped) console.log(` ${c.yellow("skip")} ${s.name.padEnd(16)} ${s.reason}`); diff --git a/implementations/cli/src/index.ts b/implementations/cli/src/index.ts index 207ca366..8f6630da 100644 --- a/implementations/cli/src/index.ts +++ b/implementations/cli/src/index.ts @@ -153,7 +153,7 @@ const baseCommands: Command[] = [ name: "provision-acl", group: "Mesh", summary: - "write the durable read-ACL row for every persona so the delivery daemon authorizes @mention-wake — provision-acl [--dry-run] [--mint-missing] [--space ]; closes the `cotal mint` + `exec omp` gap", + "write the durable read-ACL row for every persona-with-creds so the delivery daemon authorizes @mention-wake — provision-acl [--dry-run] [--space ]; closes the `cotal mint` + `exec omp` gap", run: provisionAcl, }, { diff --git a/implementations/cli/src/lib/acl-provision.ts b/implementations/cli/src/lib/acl-provision.ts index 3d033f26..9b1f8e5b 100644 --- a/implementations/cli/src/lib/acl-provision.ts +++ b/implementations/cli/src/lib/acl-provision.ts @@ -78,17 +78,24 @@ export function planAclProvision(root: string, space: string): AclProvisionPlanE plan.push({ name: p.name, allowSubscribe, hasCreds: false }); continue; } - const creds = readFileSync(credsPath, "utf8"); - const id = idFromCreds(creds); - // Parity: the file-derived ACL must render to the same chat sub.allow the creds carry, or the - // durable read scope would diverge from the live one. Compare as sorted sets. - const want = allowSubscribe.map((ch) => chatSubject(space, "*", ch)).sort(); - const have = credChatSubAllow(creds, space); - const drift = - JSON.stringify(want) === JSON.stringify(have) - ? undefined - : `creds read scope [${have.join(", ")}] != file ACL [${want.join(", ")}] — re-mint ${p.name}`; - plan.push({ name: p.name, id, allowSubscribe, hasCreds: true, drift }); + // A malformed/truncated/mismatched creds file must not abort the WHOLE catalog pass (that would + // leave later valid personas @mention-wake-blind). Isolate it as an error entry — provisionAcls + // skips just this one and continues. + try { + const creds = readFileSync(credsPath, "utf8"); + const id = idFromCreds(creds); + // Parity: the file-derived ACL must render to the same chat sub.allow the creds carry, or the + // durable read scope would diverge from the live one. Compare as sorted sets. + const want = allowSubscribe.map((ch) => chatSubject(space, "*", ch)).sort(); + const have = credChatSubAllow(creds, space); + const drift = + JSON.stringify(want) === JSON.stringify(have) + ? undefined + : `creds read scope [${have.join(", ")}] != file ACL [${want.join(", ")}] — re-mint ${p.name}`; + plan.push({ name: p.name, id, allowSubscribe, hasCreds: true, drift }); + } catch (e) { + plan.push({ name: p.name, allowSubscribe, hasCreds: false, error: `unreadable creds: ${e instanceof Error ? e.message : String(e)}` }); + } } return plan; } @@ -145,7 +152,7 @@ export async function provisionAcls(opts: { // Read-back through the public accessor confirms the row landed (and is what the daemon's reader // will see) — a real verification, not just a key tally. const back = await ep.aclForOwner(id as string); - if (JSON.stringify(back) !== JSON.stringify(e.allowSubscribe)) + if (JSON.stringify([...(back ?? [])].sort()) !== JSON.stringify([...e.allowSubscribe].sort())) throw new Error(`ACL write for ${e.name} did not read back: wrote ${JSON.stringify(e.allowSubscribe)}, read ${JSON.stringify(back)}`); result.provisioned.push({ name: e.name, id: id as string, allowSubscribe: e.allowSubscribe }); } From be1f866dab4b34f063cc3e6878b672b7ad122871 Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 17:27:48 -0400 Subject: [PATCH 3/7] test(cli): regression for bad-creds isolation in acl-provision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds smoke case #9: a persona whose on-disk creds file is corrupt is isolated as an 'unreadable creds' skip entry while a valid sibling is still provisioned — the catalog pass completes instead of throwing. 4 of the 6 assertions flip red when the planAclProvision try/catch guard is removed, proving the regression teeth. Co-Authored-By: seal --- .../cli/smoke/provision-acl-live.smoke.ts | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/implementations/cli/smoke/provision-acl-live.smoke.ts b/implementations/cli/smoke/provision-acl-live.smoke.ts index ca794f44..304204ca 100644 --- a/implementations/cli/smoke/provision-acl-live.smoke.ts +++ b/implementations/cli/smoke/provision-acl-live.smoke.ts @@ -26,7 +26,7 @@ */ import { randomUUID } from "node:crypto"; import { spawn, type ChildProcess } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -35,13 +35,14 @@ import { setupSpaceStreams, isReachable, mintCreds, + idFromCreds, newIdentity, provisionAgent, chatSubject, CotalEndpoint, } from "@cotal-ai/core"; import { authDir } from "@cotal-ai/workspace"; -import { planAclProvision, provisionAcls } from "../src/lib/acl-provision.js"; +import { planAclProvision, provisionAcls, type AclProvisionResult } from "../src/lib/acl-provision.js"; import { personasDir } from "../src/lib/personas.js"; import { resolveNatsServer } from "../src/lib/nats-bin.js"; @@ -268,6 +269,52 @@ try { { daemonLiveAfter, daemonRow }, ); + // ── 9. bad creds isolated, siblings still provision ───────────────────────────────────────── + // A malformed on-disk creds file must NOT abort the whole catalog pass (pre-fix it threw, leaving + // every later persona @mention-wake-blind). planAclProvision isolates it as an error entry and the + // loop continues, so a valid sibling is still provisioned. Corrupt baddie's creds AFTER mint so + // idFromCreds throws when the routine reads them; goodie is the valid sibling that must survive. + const rGolf = freshRoot("golf"); + writeAgent(rGolf, "goodie", "allowSubscribe: [general, ops]"); + const goodie = await mintPersona(rGolf, "goodie", ["general", "ops"]); + writeAgent(rGolf, "baddie", "allowSubscribe: [general]"); + const baddie = await mintPersona(rGolf, "baddie", ["general"]); + const baddieCreds = join(authDir(rGolf), "creds", "baddie.creds"); + writeFileSync(baddieCreds, "not-valid-creds-@@@"); // overwrite the real creds with garbage + // PRECONDITION the fix guards: the on-disk creds now fail to parse (idFromCreds throws). + let parseThrew = false; + try { + idFromCreds(readFileSync(baddieCreds, "utf8")); + } catch { + parseThrew = true; + } + check("[#9 bad-creds] PRECONDITION: the corrupt creds file fails to parse (idFromCreds throws)", parseThrew); + + let r9: AclProvisionResult | undefined; + let r9Threw = false; + try { + r9 = await run(rGolf); + } catch (e) { + r9Threw = true; + console.error(" ! #9 run threw:", (e as Error).message); + } + // The crux of the regression: one bad creds file no longer aborts the pass. + check("[#9 bad-creds] the catalog pass COMPLETED — run() did not throw on the bad creds file", !r9Threw && r9 !== undefined); + check( + "[#9 bad-creds] valid sibling 'goodie' is still provisioned despite the bad-creds neighbor", + r9?.provisioned.some((p) => p.name === "goodie") ?? false, + r9, + ); + const goodieRow = await reader.aclForOwner(goodie.id); + check("[#9 bad-creds] goodie's ACL row reads back as [general, ops]", eq(goodieRow, ["general", "ops"]), goodieRow); + const baddieSkip = r9?.skipped.find((s) => s.name === "baddie"); + check( + "[#9 bad-creds] baddie is skipped with an 'unreadable creds' reason, never provisioned", + /unreadable creds/i.test(baddieSkip?.reason ?? "") && !(r9?.provisioned.some((p) => p.name === "baddie") ?? false), + r9, + ); + const baddieRow = await reader.aclForOwner(baddie.id); + check("[#9 bad-creds] no ACL row was committed for the corrupt persona", baddieRow === undefined, baddieRow); console.log( `\nNote: #7/#8 defend the spawn daemon-gate at the ROUTINE BOUNDARY (readDeliveryLease→durableMembership→row),` + ` driven by a real lease — not a full connector-fork \`cotal spawn\` (out of harness scope).`, From f40ab371bab61e5e485e9758e12b60abf819310e Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 18:27:16 -0400 Subject: [PATCH 4/7] fix(cli): provision the full delivery footprint, not just the ACL row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged (and mercator reproduced live this session) that provision-acl authorized the owner but never created the agent's bind-only dlv_ DELIVER durable — the @mention-wake path the daemon fans out to. The agent is denied CONSUMER.CREATE on DLV, so only a provisioner can make it, and pumpDlv silently no-ops when it is absent. A `cotal mint` + `exec omp` agent (this command's target) has neither the ACL row nor the dm/dlv durables, so committing only the row left its wake messages piling undrained. provisionAcls now mirrors provisionAgent's footprint: provisionDmInbox(id) + provisionDlvInbox(id) alongside commitAcl. All three are idempotent, so the re-runnable contract holds and a spawn-provisioned agent's existing durables are untouched. The provisioner cred already grants DM/DLV CONSUMER.CREATE. Co-Authored-By: seal --- implementations/cli/src/lib/acl-provision.ts | 31 +++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/implementations/cli/src/lib/acl-provision.ts b/implementations/cli/src/lib/acl-provision.ts index 9b1f8e5b..1c786ef5 100644 --- a/implementations/cli/src/lib/acl-provision.ts +++ b/implementations/cli/src/lib/acl-provision.ts @@ -107,10 +107,15 @@ export interface AclProvisionResult { } /** - * Provision (commit) an ACL row for every persona that already has creds. A credless persona is - * SKIPPED (an ACL row is keyed by agent id, which exists only once creds are minted) — this command - * never mints: minting is `cotal mint`'s job, and mint-if-absent is the still-open mint-strategy fork. - * A drift entry (creds read scope != file ACL) is fail-loud skipped, never silently rewritten. + * Provision the durable-delivery footprint for every persona that already has creds: the bind-only + * `dm_` + `dlv_` mailboxes (which the agent cannot self-create — it's denied CONSUMER.CREATE + * on those streams) AND the read-ACL row. All three are what `provisionAgent` writes for a spawned + * agent; a `cotal mint` + `exec omp` agent gets none of them, so committing only the ACL row would + * authorize the owner while its @mention-wake messages pile undrained in an absent `dlv_`. A + * credless persona is SKIPPED (the id — hence every server-side object — exists only once creds are + * minted); this command never mints (that's `cotal mint`'s job, and mint-if-absent is the still-open + * mint-strategy fork). A drift entry (creds read scope != file ACL) is fail-loud skipped, never + * silently rewritten. Every write is idempotent, so the command is re-runnable. */ export async function provisionAcls(opts: { root: string; @@ -147,14 +152,24 @@ export async function provisionAcls(opts: { result.skipped.push({ name: e.name, reason: "no creds (run `cotal mint` first)" }); continue; } - const id = e.id; - await ep.commitAcl(id as string, e.allowSubscribe); + const id = e.id as string; + // Provision the FULL durable-delivery footprint, not just the ACL row. @mention-wake delivery + // rides the daemon's fan-out → per-member `dlv_` DELIVER durable; the agent binds+pumps it + // but is DENIED CONSUMER.CREATE on DLV (only a provisioner may create it — see provision.ts), and + // `pumpDlv` silently no-ops when it's absent. A `cotal mint` + `exec omp` agent (this command's + // target) has NEITHER the ACL row NOR the dm/dlv durables — so writing only the ACL row would + // authorize the owner while its wake messages pile undrained. Mirror provisionAgent's footprint: + // pre-create the bind-only dm+dlv mailboxes (both idempotent — re-runnable, existing durables + // untouched), then record the ACL row. + await ep.provisionDmInbox(id); + await ep.provisionDlvInbox(id); + await ep.commitAcl(id, e.allowSubscribe); // Read-back through the public accessor confirms the row landed (and is what the daemon's reader // will see) — a real verification, not just a key tally. - const back = await ep.aclForOwner(id as string); + const back = await ep.aclForOwner(id); if (JSON.stringify([...(back ?? [])].sort()) !== JSON.stringify([...e.allowSubscribe].sort())) throw new Error(`ACL write for ${e.name} did not read back: wrote ${JSON.stringify(e.allowSubscribe)}, read ${JSON.stringify(back)}`); - result.provisioned.push({ name: e.name, id: id as string, allowSubscribe: e.allowSubscribe }); + result.provisioned.push({ name: e.name, id, allowSubscribe: e.allowSubscribe }); } result.rowCount = result.provisioned.length; } finally { From 4417caa16534ee200c206ad04b49c3b0105446b2 Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 18:55:52 -0400 Subject: [PATCH 5/7] fix(cli): make provision-acl --dry-run preview the resolved mesh catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic flagged (PR #4) that --dry-run planned against cwd cotalRoot() + the raw --space flag, while the live path resolves the target and uses target.root/ target.space. A --space or out-of-checkout invocation resolves a registered mesh whose root differs from cwd, so the dry-run preview could show a different persona/drift/credless set than the command actually provisions. Add resolveTargetNoConnectOrExit — an offline sibling of resolveTargetOrExit that resolves the target from the registry with the same one-sentence error render but no connect and no prune (an offline preview must not mutate the registry). The --dry-run path now plans against the resolved target.root/space, so the preview is faithful to the live run. Co-Authored-By: seal --- .../cli/src/commands/provision-acl.ts | 11 +++++++--- implementations/cli/src/lib/connect.ts | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/implementations/cli/src/commands/provision-acl.ts b/implementations/cli/src/commands/provision-acl.ts index b9b83eeb..c7bccb24 100644 --- a/implementations/cli/src/commands/provision-acl.ts +++ b/implementations/cli/src/commands/provision-acl.ts @@ -1,6 +1,6 @@ import { parseArgs } from "node:util"; import { planAclProvision, provisionAcls } from "../lib/acl-provision.js"; -import { resolveTargetOrExit } from "../lib/connect.js"; +import { resolveTargetNoConnectOrExit, resolveTargetOrExit } from "../lib/connect.js"; import { cotalRoot } from "../lib/paths.js"; import { c } from "../ui.js"; @@ -32,9 +32,14 @@ export async function provisionAcl(argv: string[]): Promise { const root = cotalRoot(); // --dry-run: OFFLINE — show the plan (what would be provisioned/skipped) without a connection. + // Resolve the target from the registry FIRST (no connect, no prune) so the preview scans the same + // catalog the live run would: a `--space`/out-of-checkout invocation resolves a registered mesh + // whose root differs from cwd — planning against raw `cotalRoot()` + `values.space` would preview a + // different persona set than the command actually provisions. if (values["dry-run"]) { - const space = values.space ?? "main"; - const plan = planAclProvision(root, space); + const dt = resolveTargetNoConnectOrExit({ server: values.server, space: values.space }); + const space = dt.space; + const plan = planAclProvision(dt.root ?? root, space); console.log(c.bold(`provision-acl (dry run) — space "${space}" — ${plan.length} personas`)); for (const e of plan) { if (e.error) { console.log(` ${c.red("skip")} ${e.name.padEnd(16)} persona parse error: ${e.error}`); continue; } diff --git a/implementations/cli/src/lib/connect.ts b/implementations/cli/src/lib/connect.ts index 508063e5..0abc3795 100644 --- a/implementations/cli/src/lib/connect.ts +++ b/implementations/cli/src/lib/connect.ts @@ -140,6 +140,27 @@ export async function resolveTargetOrExit(flags: { return target; } +/** Offline sibling of {@link resolveTargetOrExit}: resolve WHICH mesh a command targets from the + * registry alone, with the same one-sentence error render, but WITHOUT connecting or pruning. For + * read-only/offline paths (e.g. `provision-acl --dry-run`) that must preview against the SAME + * resolved catalog the live run would use — a raw `--space`/cwd guess would show a different + * persona set than the real command. No `pruneStaleMeshes` (an offline preview must not mutate the + * registry) and no broker probe. */ +export function resolveTargetNoConnectOrExit(flags: { + server?: string; + space?: string; +}): MeshTarget { + try { + return resolveMeshTarget(process.cwd(), flags); + } catch (e) { + if (isWorkspaceTargetError(e)) { + console.error(c.red(renderWorkspaceError({ kind: "target", error: e }))); + process.exit(1); + } + throw e; + } +} + /** Confirm the resolved mesh is up and accepts these creds — replaces the raw NATS "Authorization * Violation" trace with one sentence, and prunes the entry if the broker is gone / mismatched. * The probe + classify + render live in `@cotal-ai/workspace` (shared with the manager control From 7c423fb4207d1907ccb7a36c61484de4b955cf44 Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 18:55:58 -0400 Subject: [PATCH 6/7] test(cli): assert provision-acl creates the dm+dlv durables, not just the ACL row Regression test for the delivery-footprint fix (f40ab37): a #10 block asserts that after provisionAcls, the persona's bind-only dlv_ DELIVER and dm_ DM durable consumers EXIST (via the provisioner cred's CONSUMER.INFO on DM/DLV), and that a second run is a no-op (idempotent re-create). Red-green proven: dropping the two provisionDmInbox/provisionDlvInbox calls fails all three #10 checks (got: undefined); restoring passes. 21 passed, 0 failed (was 18). Co-Authored-By: seal --- .../cli/smoke/provision-acl-live.smoke.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/implementations/cli/smoke/provision-acl-live.smoke.ts b/implementations/cli/smoke/provision-acl-live.smoke.ts index 304204ca..0eaab020 100644 --- a/implementations/cli/smoke/provision-acl-live.smoke.ts +++ b/implementations/cli/smoke/provision-acl-live.smoke.ts @@ -39,6 +39,10 @@ import { newIdentity, provisionAgent, chatSubject, + dlvStream, + dlvDurable, + dmStream, + dmDurable, CotalEndpoint, } from "@cotal-ai/core"; import { authDir } from "@cotal-ai/workspace"; @@ -315,6 +319,63 @@ try { ); const baddieRow = await reader.aclForOwner(baddie.id); check("[#9 bad-creds] no ACL row was committed for the corrupt persona", baddieRow === undefined, baddieRow); + + // ── 10. provision-acl creates the bind-only dm_ + dlv_ durables, not just the ACL row ── + // @mention-wake delivery rides the daemon's fan-out → per-member `dlv_` DELIVER durable, which the + // agent BINDS (denied CONSUMER.CREATE on DLV) and `pumpDlv` SILENTLY no-ops when it's absent. A + // `cotal mint` + `exec omp` agent has NEITHER the ACL row nor the durables, so the pre-fix ACL-row-only + // path left its @mention-wake messages piling undrained in an absent `dlv_`. This defends the fix + // that pre-creates BOTH bind-only mailboxes (`provisionDmInbox` + `provisionDlvInbox`) before the row. + // Existence is read through the reader's provisioner jsm — that cred holds CONSUMER.INFO on DM/DLV + // (provision.ts:883), and `consumers.info` RESOLVES with the durable when present, THROWS (404) when + // absent. `manager()` is TS-private on CotalEndpoint but callable at runtime; no public consumer-info + // accessor exists and `@nats-io/*` is not a CLI dep (unimportable from this smoke), so this single + // documented reach past the private surface is the only path to a jsm here. + interface JsmConsumerInfo { + manager(): Promise<{ consumers: { info(stream: string, durable: string): Promise<{ name: string }> } }>; + } + const readerJsmAccess = reader as unknown as JsmConsumerInfo; // see note above — reach the provisioner jsm + const jsm = await readerJsmAccess.manager(); + const consumerName = async (stream: string, durable: string): Promise => { + try { + return (await jsm.consumers.info(stream, durable)).name; // resolves iff the durable exists + } catch { + return undefined; // 404 — the durable was never created + } + }; + + const rHotel = freshRoot("hotel"); + writeAgent(rHotel, "hotel", "allowSubscribe: [general, ops]"); + const hotel = await mintPersona(rHotel, "hotel", ["general", "ops"]); + await run(rHotel); + const dlvName = await consumerName(dlvStream(space), dlvDurable(hotel.id)); + check( + "[#10 dlv-footprint] provision-acl pre-created the bind-only dlv_ DELIVER durable", + dlvName === dlvDurable(hotel.id), + { got: dlvName, want: dlvDurable(hotel.id) }, + ); + const dmName = await consumerName(dmStream(space), dmDurable(hotel.id)); + check( + "[#10 dlv-footprint] provision-acl pre-created the bind-only dm_ DM durable", + dmName === dmDurable(hotel.id), + { got: dmName, want: dmDurable(hotel.id) }, + ); + // Idempotency: the two new provision calls re-create existing durables as a no-op — a second run must + // NOT throw, and BOTH durables must still exist afterward (defends the new calls' "re-runnable" contract). + let hotelRerunThrew = false; + try { + await run(rHotel); + } catch (e) { + hotelRerunThrew = true; + console.error(" ! #10 second run threw:", e instanceof Error ? e.message : e); + } + const dlvAfter = await consumerName(dlvStream(space), dlvDurable(hotel.id)); + const dmAfter = await consumerName(dmStream(space), dmDurable(hotel.id)); + check( + "[#10 idempotent] a second run does not throw and both durables still exist (re-create is a no-op)", + !hotelRerunThrew && dlvAfter === dlvDurable(hotel.id) && dmAfter === dmDurable(hotel.id), + { hotelRerunThrew, dlvAfter, dmAfter }, + ); console.log( `\nNote: #7/#8 defend the spawn daemon-gate at the ROUTINE BOUNDARY (readDeliveryLease→durableMembership→row),` + ` driven by a real lease — not a full connector-fork \`cotal spawn\` (out of harness scope).`, From 669214946216494eb6cb5e9ba176622887dd7480 Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 19:04:28 -0400 Subject: [PATCH 7/7] docs(cli): document the dry-run prune divergence in the offline resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic (PR #4) noted resolveTargetNoConnectOrExit's comment claimed --dry-run previews the SAME catalog as the live run, but the live path calls pruneStaleMeshes() first (an online reachability probe that mutates the registry) and the offline preview cannot — so a since-dead registered mesh may still be resolved by --dry-run where the live run would prune it and fall back. Pruning is inherently online + mutating, so it can't run in an offline, side-effect-free preview. Corrected the JSDoc to document this one deliberate divergence (the preview errs toward the recorded target; the live run reconciles) rather than overclaim parity, and pointed the call-site comment at it. Comment-only. Co-Authored-By: seal --- implementations/cli/src/commands/provision-acl.ts | 9 +++++---- implementations/cli/src/lib/connect.ts | 14 ++++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/implementations/cli/src/commands/provision-acl.ts b/implementations/cli/src/commands/provision-acl.ts index c7bccb24..202c57b0 100644 --- a/implementations/cli/src/commands/provision-acl.ts +++ b/implementations/cli/src/commands/provision-acl.ts @@ -32,10 +32,11 @@ export async function provisionAcl(argv: string[]): Promise { const root = cotalRoot(); // --dry-run: OFFLINE — show the plan (what would be provisioned/skipped) without a connection. - // Resolve the target from the registry FIRST (no connect, no prune) so the preview scans the same - // catalog the live run would: a `--space`/out-of-checkout invocation resolves a registered mesh - // whose root differs from cwd — planning against raw `cotalRoot()` + `values.space` would preview a - // different persona set than the command actually provisions. + // Resolve the target from the registry FIRST (offline: no connect, no prune — see the helper's note + // on the one stale-entry divergence from the live path) so the preview scans the same catalog the + // live run would: a `--space`/out-of-checkout invocation resolves a registered mesh whose root + // differs from cwd — planning against raw `cotalRoot()` + `values.space` would preview a different + // persona set than the command actually provisions. if (values["dry-run"]) { const dt = resolveTargetNoConnectOrExit({ server: values.server, space: values.space }); const space = dt.space; diff --git a/implementations/cli/src/lib/connect.ts b/implementations/cli/src/lib/connect.ts index 0abc3795..0651cbc4 100644 --- a/implementations/cli/src/lib/connect.ts +++ b/implementations/cli/src/lib/connect.ts @@ -142,10 +142,16 @@ export async function resolveTargetOrExit(flags: { /** Offline sibling of {@link resolveTargetOrExit}: resolve WHICH mesh a command targets from the * registry alone, with the same one-sentence error render, but WITHOUT connecting or pruning. For - * read-only/offline paths (e.g. `provision-acl --dry-run`) that must preview against the SAME - * resolved catalog the live run would use — a raw `--space`/cwd guess would show a different - * persona set than the real command. No `pruneStaleMeshes` (an offline preview must not mutate the - * registry) and no broker probe. */ + * read-only/offline paths (e.g. `provision-acl --dry-run`) — a raw `--space`/cwd guess would scan a + * different persona set than the real command; resolving through the registry the same way the live + * path does keeps the preview's ROOT/SPACE honest. + * + * One deliberate divergence from the live path: `resolveTargetOrExit` calls `pruneStaleMeshes()` + * first (an ONLINE reachability probe that mutates the registry), so on the no-`--space` default it + * can drop a since-dead entry before resolving. This offline preview cannot — probing/mutating would + * break the "offline, side-effect-free" contract — so if a registered mesh has died since it was + * recorded, `--dry-run` may still resolve it where the live run would have pruned it and fallen back. + * Acceptable for a preview (it errs toward showing the recorded target; the live run reconciles). */ export function resolveTargetNoConnectOrExit(flags: { server?: string; space?: string;