feat(credentials): per-spawn scoped credentials — backend (TASK-094 PR A) - #1814
lilyshen0722 wants to merge 4 commits into
Conversation
…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).
|
Structure gate (Wren) at Read: merge-tree against Conforms to the row: Q1 narrow predicate ( HOLD 1 — the route header states a behaviour the design forbids. HOLD 2 — a single mint call can opt out of the 15-minute bound. Accepted as disclosed (recorded on the row, not to be discovered by PR B): route path Vera gates measurement. Re-gate on the recut head; name it. |
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
mainafter this merges — no stacking.Row: TASK-094 · Owner: wren · Gate: vera (functional), wren (structure)
Surface
/api/agents/runtime/spawn-credentials, behindagentRuntimeAuth+ a token-hash-keyed limiter:POST /{token, credentialId, spawnId, expiresAt, maxExpiresAt}. The plaintext token is returned once and never stored — the ledger keeps only its hashPOST /:id/renew{expiresAt, extended}DELETE /:id{revoked}POST /revoke-orphans{revoked}GET /policy{defaultTtlSeconds: 900, absoluteLifetimeSeconds: 86400, maxSpawnIdLength: 128}— the numbers the cli renews againstRefusals are typed, never silent:
child_cannot_mint403,invalid_spawn_id/invalid_ttl/ malformed id 400,not_found404,not_renewable409, unauthenticated 401.Also in this PR:
backend/services/spawnCredentialService.ts— mint,resolveSeatCredential($setOnInsertbackfill, idempotent: it never overwrites status/lineage/expiry, so it cannot resurrect a revoked credential), renew, revoke, orphan sweep.backend/middleware/agentRuntimeAuth.ts— acm_agent_*token that names a spawn-scoped credential authenticates as its seat (installs the seat's installations on the request), andreq.agentTokenHash/req.agentCredentialare now set. The predicate is deliberately narrow at wren's Q1:agentUseris resolved fromcredential.agentUserIdonly whencredential.scopesincludesspawn— a legacy-shaped row stays a veto and never becomes an authority.backend/models/AgentCredential.ts— newmaxExpiresAtfield (additive, defaultnull, 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-configand 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:
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))
finallydeletes the per-spawn directory, so a test that reads the file after the spawn ends reads nothing.revokedafter thefinally;expiredafterexpiresAtpassed untouched — covered byagentCredential.substrate.test.js, which asserts the row is stillactivewithexpiresAtin the past, so the crash case cannot pass because something revoked it.child_cannot_mint(a leaked file must not manufacture a longer-lived credential).finallysuppressed (SIGKILL), 401 within one TTL and the row stillactive. The TTL is the authority; the revoke-on-exit is the optimisation.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.js— 16 tests (route contract, refusal mapping, caller-supplied fields ignored, backfill economy, sweep filter).backend/__tests__/unit/services/spawnCredentialService.test.js— 36 tests.backend/__tests__/unit/services/agentCredential.substrate.test.js— 15 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.server.test.js+leakMatrix.{ratchet,agentRuntime}.test.js22/22;eslinton every touched file 0 errors./tmp/kai094-mutate.py10 on the service,/tmp/kai094-auth-mutate.py5 on the auth change,/tmp/kai094-mutate2.py6 on renewal/ceiling/controls,/tmp/kai094-routes-mutate.py7 on the routes), each restoring green.Two testing notes worth keeping, both earned here:
expiresAtagainstSPAWN_TTL_DEFAULT_SECONDSand survived a 12-hour mutation of that constant. The ruling's numbers are now pinned as literals..tsfile 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)
active-but-expired row, that is a one-line change plus a test.maxExpiresAtis measured from the mint, not from the last renewal, so a supervisor renewing forever cannot push the absolute ceiling forward.Out of scope
COMMONLY_AGENT_TOKEN, renew on a cadence, revoke on exit (preferdefault,requirefor seats that want the hard failure). Opens only after this is onmain.commonly-mcpreads the credential per request and the broker entries can accept a rotating value.Test plan
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.