Skip to content
407 changes: 407 additions & 0 deletions implementations/cli/smoke/provision-acl-live.smoke.ts

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions implementations/cli/src/commands/provision-acl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { parseArgs } from "node:util";
import { planAclProvision, provisionAcls } from "../lib/acl-provision.js";
import { resolveTargetNoConnectOrExit, 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_<space>`) 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<void> {
const { values } = parseArgs({
args: argv,
options: {
server: { type: "string" },
space: { type: "string" },
"dry-run": { type: "boolean" },
},
});
Comment thread
seal-agent marked this conversation as resolved.
const root = cotalRoot();

// --dry-run: OFFLINE — show the plan (what would be provisioned/skipped) without a connection.
// 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;
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; }
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);
}
// 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 });
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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}"`,
);
}
17 changes: 11 additions & 6 deletions implementations/cli/src/commands/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,19 +241,24 @@ export async function spawn(argv: string[]): Promise<void> {
});
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.<id>` 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,
Comment thread
seal-agent marked this conversation as resolved.
});
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
Expand Down
9 changes: 9 additions & 0 deletions implementations/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -147,6 +148,14 @@ const baseCommands: Command[] = [
"mint a creds file for a space (auth mode) — mint <name> --profile <agent|observer> [--out <path>]; --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-with-creds so the delivery daemon authorizes @mention-wake — provision-acl [--dry-run] [--space <s>]; closes the `cotal mint` + `exec omp` gap",
run: provisionAcl,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
kind: "command",
name: "topology",
Expand Down
179 changes: 179 additions & 0 deletions implementations/cli/src/lib/acl-provision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
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_<space>` 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
Comment thread
seal-agent marked this conversation as resolved.
* 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 {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
nats?: { sub?: { allow?: string[] } };
};
const prefix = `${chatSubject(space, "*", "").slice(0, -1)}`; // cotal.<space>.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;
}
// 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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export interface AclProvisionResult {
provisioned: { name: string; id: string; allowSubscribe: string[] }[];
skipped: { name: string; reason: string }[];
rowCount: number;
}

/**
* Provision the durable-delivery footprint for every persona that already has creds: the bind-only
* `dm_<id>` + `dlv_<id>` 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_<id>`. 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;
space: string;
server: string;
auth: SpaceAuth;
}): Promise<AclProvisionResult> {
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 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_<id>` 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);
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, allowSubscribe: e.allowSubscribe });
}
result.rowCount = result.provisioned.length;
} finally {
await ep.stop();
}
return result;
}
27 changes: 27 additions & 0 deletions implementations/cli/src/lib/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,33 @@ 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`) — 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: {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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
Expand Down
Loading