Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions implementations/cli/smoke/mint-reuse.smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* `cotal mint` identity-reuse smoke (hermetic — no broker). Exercises the REAL mint() command against
* a tmp `.cotal/` root laid out exactly as `cotal up` writes it (auth.json via saveSpaceAuth + a
* persona file), and asserts the reuse-unless-force contract end to end. Run with:
* pnpm --filter @cotal-ai/cli exec tsx smoke/mint-reuse.smoke.ts
*
* The load-bearing regression: re-minting an AGENT keeps the SAME nkey id (so its durable ACL row +
* dm/dlv durables, all id-keyed, stay valid) — the old unconditional newIdentity() rotated the id on
* every mint and orphaned them, leaving the agent @mention-wake-blind. --force rotates deliberately;
* a persona ACL change refreshes the baked channels WITHOUT rotating; observer/admin always rotate
* (reuse is agent-only — no durable footprint to orphan, and re-signing a privileged key would
* silently extend its lifetime); and a present-but-unparseable creds file fails loud (never a silent
* fresh-mint that would orphan the id its predecessor may still own).
*
* mint() resolves its root via findCotalRoot() walking up from process.cwd(), so the harness chdir's
* into the tmp root and restores cwd in the finally.
*/
import { strict as assert } from "node:assert";
import { randomUUID } from "node:crypto";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createSpaceAuth, idFromCreds } from "@cotal-ai/core";
import { authDir, saveSpaceAuth } from "@cotal-ai/workspace";
import { mint } from "../src/commands/mint.js";

let failures = 0;
function check(label: string, cond: boolean, extra?: unknown): void {
console.log(`${cond ? "✓" : "✗"} ${label}${cond ? "" : ` — ${JSON.stringify(extra)}`}`);
if (!cond) failures++;
}

// The chat-read channels a minted creds file grants (decode the JWT's nats.sub.allow, keep chat.*.<ch>
// entries). Same JWT-decode shape as manager/smoke/persona-identity-acl.smoke.ts.
function credSubChat(path: string): string[] {
const jwt = readFileSync(path, "utf8").split("\n").find((l) => l && !l.startsWith("-") && l.split(".").length === 3)!;
const claims = JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString("utf8"));
const allow: string[] = claims.nats?.sub?.allow ?? [];
return allow.filter((s) => s.includes(".chat."));
}

// mint() logs its result to stdout; silence it (restored even on throw) so the check output stays
// legible — the ids we assert on are read straight from the written creds file, not from the log.
async function mintQuiet(argv: string[]): Promise<void> {
const orig = console.log;
console.log = () => {};
try {
await mint(argv);
} finally {
console.log = orig;
}
}

// A tmp `.cotal/` root with real space trust material (auth.json, exactly as `cotal up` persists it)
// and a `scout` agent persona reading/posting the `general` channel.
const space = `mint-reuse-${randomUUID().slice(0, 8)}`;
const auth = await createSpaceAuth(space);
const root = mkdtempSync(join(tmpdir(), "cotal-mint-reuse-"));
const agentsDir = join(root, ".cotal", "agents");
mkdirSync(agentsDir, { recursive: true });
saveSpaceAuth(authDir(root), auth);
const scoutPersona = join(agentsDir, "scout.md");
writeFileSync(scoutPersona, "---\nname: scout\nsubscribe: [general]\nallowSubscribe: [general]\nallowPublish: [general]\n---\nbody\n");
const scoutCreds = join(authDir(root), "creds", "scout.creds");
const credsDir = join(authDir(root), "creds");

const prevCwd = process.cwd();
process.chdir(root); // findCotalRoot() walks up from cwd — anchor it at our tmp root
try {
// 1) THE regression: re-minting an agent twice reuses the SAME id (before the fix, two mints gave
// two different ids and orphaned the id-keyed durables).
await mintQuiet(["scout"]);
const id1 = idFromCreds(readFileSync(scoutCreds, "utf8"));
await mintQuiet(["scout"]);
const id2 = idFromCreds(readFileSync(scoutCreds, "utf8"));
check("re-mint of an agent reuses the same id", id1 === id2, { id1, id2 });

// 2) --force is the deliberate-rotation escape hatch: a fresh id despite an existing creds file.
await mintQuiet(["scout", "--force"]);
const id3 = idFromCreds(readFileSync(scoutCreds, "utf8"));
check("mint --force rotates to a different id", id3 !== id2, { id2, id3 });

// 3) A persona ACL change is applied on re-mint (refreshed channels) WITHOUT rotating the id —
// the whole point: an agent's read scope is refreshed while its mesh id stays stable.
const beforeChans = credSubChat(scoutCreds); // channels baked before the persona change
writeFileSync(scoutPersona, "---\nname: scout\nsubscribe: [general]\nallowSubscribe: [general, review]\nallowPublish: [general]\n---\nbody\n");
await mintQuiet(["scout"]);
const id4 = idFromCreds(readFileSync(scoutCreds, "utf8"));
const afterChans = credSubChat(scoutCreds);
check("re-mint after an ACL change keeps the identity", id4 === id3, { id3, id4 });
check(
"re-mint bakes the NEW channel into sub.allow (review added, was absent before)",
afterChans.some((s) => s.endsWith(".chat.*.review")) && !beforeChans.some((s) => s.endsWith(".chat.*.review")),
{ beforeChans, afterChans },
);

// 4) First-ever mint of a brand-new name has no creds file — the reuse=false path must mint a fresh
// identity and write valid creds, not crash on the absent-file read.
writeFileSync(join(agentsDir, "newbie.md"), "---\nname: newbie\nsubscribe: [general]\nallowSubscribe: [general]\n---\nbody\n");
const newbieCreds = join(credsDir, "newbie.creds");
await mintQuiet(["newbie"]);
const idNew = idFromCreds(readFileSync(newbieCreds, "utf8"));
check("first mint of a brand-new name mints a fresh identity (absent-creds path, no crash)", idNew !== id4 && idNew.startsWith("U"), { idNew, id4 });

// 5) Reuse is AGENT-ONLY: re-minting an observer or admin creds file ROTATES the id (a privileged
// key must not silently extend its lifetime across re-mints).
for (const profile of ["observer", "admin"] as const) {
const pOut = join(credsDir, `${profile}-dash.creds`);
await mintQuiet([`${profile}-dash`, "--profile", profile]);
const a = idFromCreds(readFileSync(pOut, "utf8"));
await mintQuiet([`${profile}-dash`, "--profile", profile]);
const b = idFromCreds(readFileSync(pOut, "utf8"));
check(`re-mint of an ${profile} creds rotates the id (reuse is agent-only)`, a !== b, { profile, a, b });
}

// 6) A present-but-unparseable creds file at the out path fails LOUD naming --force — never a raw
// parse crash, and never a silent fresh-mint (which would orphan the predecessor id's durables).
mkdirSync(credsDir, { recursive: true });
const corruptCreds = join(credsDir, "corrupt.creds");
writeFileSync(corruptCreds, ""); // present but no seed block
let msg = "";
try {
await mintQuiet(["corrupt"]);
} catch (e) {
msg = e instanceof Error ? e.message : String(e);
}
check(
"unparseable existing creds → actionable error naming --force (no silent rotate, no raw crash)",
/could not be parsed/.test(msg) && /--force/.test(msg),
{ msg },
);
// Assert the FILESYSTEM state, not just the message: the failed mint must not have written fresh
// creds over the corrupt file. A future refactor that mints-then-throws would still surface a
// parse error yet silently rotate the id — this catches that by proving the file is untouched.
check(
"failed corrupt-creds mint left the file untouched (no silent fresh-mint)",
readFileSync(corruptCreds, "utf8") === "",
{ content: readFileSync(corruptCreds, "utf8").slice(0, 40) },
);
} finally {
process.chdir(prevCwd);
rmSync(root, { recursive: true, force: true });
}

console.log(`\nmint-reuse smoke: ${failures === 0 ? "OK ✅" : "FAILED ❌"} (${failures} failing)`);
assert.equal(failures, 0, `${failures} check(s) failed`);
process.exit(0);
38 changes: 33 additions & 5 deletions implementations/cli/src/commands/mint.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { resolve, join, dirname } from "node:path";
import { parseArgs } from "node:util";
import {
agentFilePath,
identityFromCreds,
loadAgentFile,
mintCreds,
mkSecretDir,
newIdentity,
stripSpaceAuth,
writeSecretFile,
type Identity,
type Profile,
} from "@cotal-ai/core";
import { authDir, loadSpaceAuth } from "@cotal-ai/workspace";
Expand All @@ -28,7 +30,7 @@ export async function mint(argv: string[]): Promise<void> {
profile: { type: "string" },
out: { type: "string" },
signer: { type: "boolean" }, // emit a stripped signer file instead of agent/observer creds
force: { type: "boolean" }, // (--signer) overwrite an existing signer file
force: { type: "boolean" }, // overwrite an existing --signer file; or (agent) rotate to a fresh id instead of reusing the existing creds'
"allow-subscribe": { type: "string" }, // read ACL override (comma-separated)
"allow-publish": { type: "string" }, // post ACL override (comma-separated)
},
Expand Down Expand Up @@ -85,12 +87,38 @@ export async function mint(argv: string[]): Promise<void> {
allowPublish = splitList(values["allow-publish"]) ?? def?.allowPublish;
role = def?.role;
}
const identity = newIdentity();
const creds = await mintCreds(auth, identity, profile, { allowSubscribe, allowPublish, role });
// Re-mint reuses the SAME identity by default — but only for the `agent` profile. mint's read/post
// ACLs come from the persona file, so re-minting is how an agent's channels get refreshed; and the
// mesh id, its durable ACL row, and its dm/dlv durables are all keyed by the nkey public key, so
// rotating the id on every mint (the old behavior) orphaned that row + those durables, leaving the
// agent @mention-wake-blind until re-provisioned. Observer/admin creds carry no persona-refresh
// workflow and no durable footprint to orphan, and silently extending a privileged admin key's
// lifetime across re-mints would be surprising — so they always rotate. Reuse only when: agent
// profile, a creds file already exists here, and --force did not ask for deliberate rotation
// (a compromised key / intentional new identity).
const out = resolve(values.out ?? join(dir, "creds", `${name}.creds`));
const reuse = profile === "agent" && !values.force && existsSync(out);
let identity: Identity;
if (reuse) {
try {
identity = identityFromCreds(readFileSync(out, "utf8"));
} catch (e) {
// A present-but-unreadable creds file (empty, truncated, or not a user creds file) must fail
// loud, not silently rotate — silently minting a fresh id here would orphan the durable row
// the existing id may still own. Point the operator at the deliberate-rotation escape hatch.
throw new Error(
`cotal mint: creds already exist at ${out} but could not be parsed to reuse the identity ` +
`(${e instanceof Error ? e.message : String(e)}). Pass --force to mint a fresh identity ` +
`(rotates the id), or remove the file if it is stale.`,
);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} else {
identity = newIdentity();
}
const creds = await mintCreds(auth, identity, profile, { allowSubscribe, allowPublish, role });
mkSecretDir(dirname(out));
writeSecretFile(out, creds);
console.log(c.green(`✓ minted ${profile} creds for "${name}"`));
console.log(c.dim(` id: ${identity.id}`));
console.log(c.dim(` id: ${identity.id}${reuse ? " (reused — re-mint kept the identity)" : " (new)"}`));
console.log(c.dim(` creds: ${out}`));
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"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:identity && pnpm smoke:mint-reuse",
"smoke:identity": "tsx packages/core/smoke/identity.smoke.ts",
"smoke:mint-reuse": "tsx implementations/cli/smoke/mint-reuse.smoke.ts",
"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",
Expand Down
51 changes: 51 additions & 0 deletions packages/core/smoke/identity.smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* identityFromCreds unit smoke (pure, no broker) — run with:
* pnpm --filter @cotal-ai/core exec tsx smoke/identity.smoke.ts
*
* Defends the id-preserving creds reader that `cotal mint` uses to REUSE an agent's identity across a
* re-mint (the reuse-unless-force fix). The contract: the {id, seed} carried by a creds file round-trips
* unchanged; the id agrees with idFromCreds; and a creds file that is corrupt (no seed block) or spliced
* (a seed paired with a foreign JWT subject) is REJECTED — never silently handing back a wrong or
* mismatched id, which is exactly what would let a re-mint re-sign the wrong identity.
*/
import assert from "node:assert/strict";
import { createSpaceAuth, mintCreds } from "../src/provision.js";
import { newIdentity, idFromCreds, identityFromCreds } from "../src/identity.js";

// Offline key material — createSpaceAuth mints an operator→account chain locally, no broker needed.
const auth = await createSpaceAuth("test");

// (a) Round-trip: a freshly-minted agent creds file yields back the SAME id AND seed (both fields).
// This is the load-bearing property — reuse re-signs THIS identity, so a wrong id or a mangled
// seed here would silently rotate the agent despite the "reuse" intent.
{
const id = newIdentity();
const creds = await mintCreds(auth, id, "agent", { allowSubscribe: ["general"] });
const got = identityFromCreds(creds);
assert.equal(got.id, id.id, "identityFromCreds must recover the minted id");
assert.equal(got.seed, id.seed, "identityFromCreds must recover the minted seed verbatim");
}

// (b) Single-id cross-consistency at the API boundary: the id it returns agrees with idFromCreds on
// the same creds (the "one id everywhere" invariant — the two readers must never diverge).
{
const creds = await mintCreds(auth, newIdentity(), "agent", { allowSubscribe: ["general"] });
assert.equal(identityFromCreds(creds).id, idFromCreds(creds));
}

// (c) Spliced creds (A's seed + B's JWT ⇒ JWT subject ≠ seed identity) is REJECTED. identityFromCreds
// inherits idFromCreds's JWT-subject cross-check, so it can't return a seed whose JWT claims a
// different identity — the guard against re-signing a seed that was paired with someone else's JWT.
{
const a = await mintCreds(auth, newIdentity(), "agent", { allowSubscribe: ["general"] });
const b = await mintCreds(auth, newIdentity(), "agent", { allowSubscribe: ["general"] });
const jwtBlock = /-----BEGIN NATS USER JWT-----[\s\S]*?------END NATS USER JWT------/;
const bJwt = b.match(jwtBlock)![0];
const spliced = a.replace(jwtBlock, bJwt); // A's seed block, B's JWT subject
assert.throws(() => identityFromCreds(spliced), /!= JWT subject/);
}

// (d) A creds string with no seed block throws the documented error rather than returning a bogus id.
assert.throws(() => identityFromCreds("this is not a creds file"), /no user nkey seed block found/);

console.log("identity.smoke: all assertions passed");
12 changes: 12 additions & 0 deletions packages/core/src/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,15 @@ export function idFromCreds(creds: string): string {
if (sub && sub !== id) throw new Error(`creds: seed identity ${id} != JWT subject ${sub}`);
return id;
}

/** The full identity (id + seed) carried by a creds file — the id-preserving sibling of
* {@link idFromCreds}. Re-mint reads this to RE-SIGN the SAME identity (stable id) with refreshed
* ACLs instead of rotating to a fresh nkey, so the agent's durable ACL row and dm/dlv durables (all
* keyed by id) stay valid across a re-mint. `idFromCreds` supplies the id AND its JWT-subject
* cross-check (a spliced seed+JWT throws there); the seed is the same block it validates, returned
* raw for {@link mintCreds} to re-embed. */
export function identityFromCreds(creds: string): Identity {
const seedM = creds.match(/BEGIN USER NKEY SEED-----\s*([\s\S]*?)\s*------END USER NKEY SEED/);
if (!seedM) throw new Error("creds: no user nkey seed block found");
return { id: idFromCreds(creds), seed: seedM[1].trim() };
}