forked from Cotal-AI/Cotal
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cli): cotal provision-acl + spawn provision the full durable-delivery footprint #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mattwilkinsonn
wants to merge
7
commits into
main
Choose a base branch
from
cotal-durable-acl-provision
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
7bd7574
feat(cli): cotal provision-acl + spawn provisions durable ACL when da…
mattwilkinsonn ade0c9f
fix(cli): harden acl-provision per PR #4 review (round 1)
mattwilkinsonn be1f866
test(cli): regression for bad-creds isolation in acl-provision
mattwilkinsonn f40ab37
fix(cli): provision the full delivery footprint, not just the ACL row
mattwilkinsonn 4417caa
fix(cli): make provision-acl --dry-run preview the resolved mesh catalog
mattwilkinsonn 7c423fb
test(cli): assert provision-acl creates the dm+dlv durables, not just…
mattwilkinsonn 6692149
docs(cli): document the dry-run prune divergence in the offline resolver
mattwilkinsonn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" }, | ||
| }, | ||
| }); | ||
| 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 }); | ||
|
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}"`, | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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 { | ||
|
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; | ||
| } | ||
|
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; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.