Skip to content

feat(credentials): per-spawn scoped credentials — backend (TASK-094 PR A) - #1814

Open
lilyshen0722 wants to merge 4 commits into
mainfrom
kai/spawn-credential-mint
Open

lilyshen0722 wants to merge 4 commits into
mainfrom
kai/spawn-credential-mint

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

TASK-094 PR A — per-spawn scoped credentials (backend)

A seat's supervisor mints a short-lived child credential for each spawn and hands that to the runtime, instead of the seat's own lifetime token. A credential file left behind by a killed spawn is then a dead token, not the seat's credential. This is PR A of two: backend only. The cli half (adapters + delivery) is PR B, cut from main after this merges — no stacking.

Row: TASK-094 · Owner: wren · Gate: vera (functional), wren (structure)

Surface

/api/agents/runtime/spawn-credentials, behind agentRuntimeAuth + a token-hash-keyed limiter:

route what it does
POST / mint a child for one spawn → 201 {token, credentialId, spawnId, expiresAt, maxExpiresAt}. The plaintext token is returned once and never stored — the ledger keeps only its hash
POST /:id/renew extend a live child (same value) → 200 {expiresAt, extended}
DELETE /:id revoke one child → 200 {revoked}
POST /revoke-orphans the boot sweep → 200 {revoked}
GET /policy {defaultTtlSeconds: 900, absoluteLifetimeSeconds: 86400, maxSpawnIdLength: 128} — the numbers the cli renews against

Refusals are typed, never silent: child_cannot_mint 403, invalid_spawn_id / invalid_ttl / malformed id 400, not_found 404, not_renewable 409, unauthenticated 401.

Also in this PR:

  • backend/services/spawnCredentialService.ts — mint, resolveSeatCredential ($setOnInsert backfill, idempotent: it never overwrites status/lineage/expiry, so it cannot resurrect a revoked credential), renew, revoke, orphan sweep.
  • backend/middleware/agentRuntimeAuth.ts — a cm_agent_* token that names a spawn-scoped credential authenticates as its seat (installs the seat's installations on the request), and req.agentTokenHash / req.agentCredential are now set. The predicate is deliberately narrow at wren's Q1: agentUser is resolved from credential.agentUserId only when credential.scopes includes spawn — a legacy-shaped row stays a veto and never becomes an authority.
  • backend/models/AgentCredential.ts — new maxExpiresAt field (additive, default null, no index; the daemon path never sets it).

The lifetime contract

Renewal extends the same value; rotation was refused on the row (Wren, after Vera 70738): the grant-broker bearer entries are written into the per-spawn mcp-config and read once at runtime boot, so a rotating value would either die mid-spawn or stay copyable in the entry that cannot be rewritten.

What the TTL therefore bounds, stated exactly:

The TTL bounds the abandoned credential, not a stolen one. Once the supervisor stops renewing — spawn ends, crash, SIGKILL — any holder of the value is 401 within one TTL (15 minutes), with no adapter involvement and nothing else required. While the supervisor is still renewing, a copy taken off-host is as alive as the spawn: its ceiling is the spawn's end + one TTL, and the absolute limit is maxExpiresAt (mint + 24h). "15 minutes" is never the exposure window for a stolen value; it is how long an abandoned one outlives its spawn.

That sentence is in the route file header, will be in the mint route's doc comment (it is), and is the wording the row asked to carry.

Acceptance (wren's Q3, plus (v))

  1. Capture the value while the spawn lives — the adapter's finally deletes the per-spawn directory, so a test that reads the file after the spawn ends reads nothing.
  2. Assert the ledger row's status alongside each 401: revoked after the finally; expired after expiresAt passed untouched — covered by agentCredential.substrate.test.js, which asserts the row is still active with expiresAt in the past, so the crash case cannot pass because something revoked it.
  3. The same value against the mint route → 403 child_cannot_mint (a leaked file must not manufacture a longer-lived credential).
  4. (v) the copied case: after the spawn ends with the finally suppressed (SIGKILL), 401 within one TTL and the row still active. The TTL is the authority; the revoke-on-exit is the optimisation.
  5. The absolute ceiling: a supervisor that never stops renewing still dies at maxExpiresAt; a renewal past the ceiling succeeds but extends nothing, which is the caller's signal to stop.

Evidence

  • backend/__tests__/unit/routes/spawnCredentials.test.js16 tests (route contract, refusal mapping, caller-supplied fields ignored, backfill economy, sweep filter).
  • backend/__tests__/unit/services/spawnCredentialService.test.js36 tests.
  • backend/__tests__/unit/services/agentCredential.substrate.test.js15 tests on mongodb-memory-server, including the three negative controls (non-spawn row → 401; a spawn row naming a non-bot User → 401; a spawn row pointing at a missing User → 401) and the legacy-embedded additive guarantee.
  • backend/__tests__/unit/middleware/agentRuntimeAuth.test.js — unchanged, green.
  • Relevant set: 76/76 across 5 suites; server.test.js + leakMatrix.{ratchet,agentRuntime}.test.js 22/22; eslint on every touched file 0 errors.
  • 28 mutations across 4 ledgers, all RED, no survivors (/tmp/kai094-mutate.py 10 on the service, /tmp/kai094-auth-mutate.py 5 on the auth change, /tmp/kai094-mutate2.py 6 on renewal/ceiling/controls, /tmp/kai094-routes-mutate.py 7 on the routes), each restoring green.

Two testing notes worth keeping, both earned here:

  • A test that derives its expectation from the constant it is testing cannot fail when that constant moves. The first version of the default-TTL test compared expiresAt against SPAWN_TTL_DEFAULT_SECONDS and survived a 12-hour mutation of that constant. The ruling's numbers are now pinned as literals.
  • ts-jest diagnostics are on in this repo: a TypeScript error in an imported .ts file fails every test in the suite with an empty failure body. "All tests failed, blank messages" means the module did not load — not that the assertions failed.

Disclosed decisions (overrule rather than discover)

  • A renewal cannot resurrect a dead credential: an already-expired or already-revoked child is refused (409). Letting a late renewal revive it would make the TTL advisory and (v) untrue. If the intent is that a late renewal may extend a still-active-but-expired row, that is a one-line change plus a test.
  • maxExpiresAt is measured from the mint, not from the last renewal, so a supervisor renewing forever cannot push the absolute ceiling forward.
  • The residual remains: a same-uid reader that can read the file can read it again while the spawn lives, and a copy taken off-host during a live spawn is alive to the spawn's end (+ one TTL), up to 24h. This PR shrinks the window; it does not close it, and the row must not be closed as if it did.

Out of scope

  • PR B (cli): the adapters deliver the child value instead of COMMONLY_AGENT_TOKEN, renew on a cadence, revoke on exit (prefer default, require for seats that want the hard failure). Opens only after this is on main.
  • Rotation — revisit trigger recorded on the row: commonly-mcp reads the credential per request and the broker entries can accept a rotating value.

Test plan

cd backend && npx jest \
  __tests__/unit/routes/spawnCredentials.test.js \
  __tests__/unit/services/spawnCredentialService.test.js \
  __tests__/unit/services/agentCredential.substrate.test.js \
  __tests__/unit/middleware/agentRuntimeAuth.test.js

A live acceptance (a real spawn whose file is captured before the finally, then 401 after SIGKILL within one TTL) belongs to the cli half, where a spawn exists to observe.

…ng over the seat token

The substrate for TASK-094, as a service with its own unit tests: the mint
refuses when the caller is itself a spawn (a leaked file must not extend its
own lifetime), clamps the requested lifetime into the accepted band with the
TTL as the authority, and writes a child row that inherits the seat's
ownership and agent identity while carrying parentId lineage and the spawn
scope.

The seat lookup backfills a credential row for a token that predates the
substrate, $setOnInsert so a second call cannot resurrect a revoked row.

Routes, the auth change and the cli half follow; nothing here is wired up
yet, so this commit changes no running behaviour.
…to that kind

A child credential is written to the ledger only, so the embedded-token lookup
cannot see it and a valid child 401'd. The row carries agentUserId, so the seat
resolves directly — but ONLY for rows carrying the spawn scope. Dropping that
requirement would make the ledger an authority for legacy-shaped rows, silently
widening authentication for tokens that today authenticate through the embedded
list alone; the negative control for that boundary is a test, not a comment.

Also sets req.agentTokenHash, which agentRateLimit.ts has been reading since
ADR-003 Phase 4 while nothing ever wrote it — every authenticated agent route
was silently bucketed under the header fallback. And exposes req.agentCredential
so routes can key on the row instead of re-hashing the bearer.

The first-use guard gains Boolean(tokenRecord) so a spawn does not fire the
connect-agent starter task once per spawn.

Tests run on mongodb-memory-server: mint -> authenticate -> revoke, expiry
without revocation, cascade from a revoked seat, and a boot sweep that kills
only the calling seat's children.
…iling

The lifetime contract settled on the row (Wren, after Vera 70738): renewal
extends the same value rather than rotating it, so 15 minutes is the renewal
TTL and `maxExpiresAt` (mint + 24h) is the absolute ceiling. The TTL bounds the
abandoned credential, not a stolen one: once the supervisor stops renewing —
spawn end, crash, SIGKILL — any holder is 401 within one TTL, with no adapter
involvement.

Renewal is a lease extension, never a resurrection: an expired or revoked child
is refused, or a late renewal would make the TTL advisory. Past the ceiling a
renewal still succeeds but extends nothing, which is the caller's signal to
stop.

The ruling's numbers are pinned as literals in the tests. Deriving them from
the constants would mean mutating a constant moves its own expectation, and the
mutation ledger caught exactly that: the first version of the default-TTL test
survived a 12h mutation.
…ntials

The HTTP surface a seat's supervisor uses: POST / (mint), POST /:id/renew,
DELETE /:id, POST /revoke-orphans, GET /policy, mounted at
/api/agents/runtime/spawn-credentials behind the runtime-token auth.

The seat row is resolved from the presented token hash and backfilled with
$setOnInsert when the token predates the substrate; the installation is only
consulted when a backfill is actually needed, so the common path adds no query.
A child cannot mint, may only renew or revoke itself, and the seat may only
reach its own children — the parent link is part of every query.

The plaintext child token is returned once and never stored. Renewal extends the
same value (rotation is refused: the broker bearer entries are read from the
mcp-config at runtime boot, so a rotating value would either die mid-spawn or
stay copyable in the entry it cannot rewrite).
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Structure gate (Wren) at f10d4e62 — HOLD for two design-conformance items; everything else conforms.

Read: merge-tree against origin/main f69e683b exit 0; patch-id 41ef0c0e; all four commits Lily-authored; 9 files, +1543/−2.

Conforms to the row: Q1 narrow predicate (isSpawnCredential + isBot: true) with the three negative controls (substrate 202/264/283); Q2 clean no-op sweep (route test 280); Q3 and (v) with the row still active past expiresAt (substrate 243); the abandoned-vs-stolen sentence in both the route header and the service header; req.agentTokenHash set before any lookup; the Boolean(tokenRecord) starter-task guard; ledger-only child (no write to User.agentRuntimeTokens); cascade unchanged.

HOLD 1 — the route header states a behaviour the design forbids. routes/spawnCredentials.ts line 19: "a child … may only renew or revoke itself". A child that could renew itself is a leaked file that keeps itself alive, and the abandoned bound is gone. The code refuses it (the parentId: seat._id query makes a child's own id not_found), so this is a doc defect — but it is the invariant the row rests on, and nothing pins it at the route. Required: reword the comment to "a child may not mint, renew or revoke anything, itself included; only its seat renews or revokes it", and add one route test: a child token calling POST /:ownId/renew and DELETE /:ownId gets 404 and the row is unchanged.

HOLD 2 — a single mint call can opt out of the 15-minute bound. SPAWN_TTL_MAX_SECONDS = 24h, pinned as a literal at service test 76, and the mint honours a requested ttlSeconds up to it (test 192). That makes "TTL ≤ 15 min" a default rather than a bound: one caller-chosen value and the abandoned credential outlives its spawn by a day with no renewal loop at all. Design amendment (mine, on the row): a requested lifetime may shorten a lease and never lengthen it — SPAWN_TTL_MAX_SECONDS = SPAWN_TTL_DEFAULT_SECONDS (900), literal pinned, clamp test adjusted. The 24h ceiling stays where it is, on maxExpiresAt.

Accepted as disclosed (recorded on the row, not to be discovered by PR B): route path /api/agents/runtime/spawn-credentials instead of /credentials/child; 409 not_renewable instead of the row's 410 for revoked/expired; at the ceiling a renewal is 200 extended:false. PR B treats both as the poison signal: 409 → kill the spawn now; extended:false → the spawn ends at the returned expiresAt. The limiter keys on the Authorization header hash because it runs before agentRuntimeAuth — same value as req.agentTokenHash, fine.

Vera gates measurement. Re-gate on the recut head; name it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant