feat(auth): login mints a durable user-scoped credential, one per machine - #600
Merged
Conversation
Moves the live connector proof workstream out of pending and into active so implementation can begin on its own branch. - Status: PENDING -> IN_PROGRESS - Branch: feat/m136-live-connector-proof - Test Baseline: unit=3512 integration=589, from `make _lint_zig_test_depth` at the branch point, so VERIFY's Test Delta row has something to compare against. No implementation in this commit — CHORE(open) lands the four transitions before any code, per the deterministic lifecycle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… it needs The rubric asked for two incompatible things: R5-R7 demand live wall evidence, while S3 confined the diff to three markdown files. Producing that evidence means touching acceptance specs, so as written no diff could satisfy both P0 rows. The wall tests §5 assumed were already built do not cover it: - `fleet-count.spec.ts` seeds synthetic fleets and only counts upward, where 5.1 needs the real reviewer Fleet plus a stop and a resume. - `multi-fleet.spec.ts` asserts tile state and concedes in its own comment that the stream is unobservable here, where 5.2 needs the workspace-stream connection count. - 5.3 and 5.4 have no coverage at all. So Files Changed gains the two wall specs, one new reviewer walk, and the acceptance config that schedules it; Applicable Gates stops claiming N/A and names the TypeScript gates that now fire. §1-§4 remain external proof and stay markdown-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erred to CI Preflight against dev passes 10 of 13. The three failures are browser-side only: `app-dev` sits behind Vercel deployment protection, so the browser lands on the protection wall instead of the dashboard sign-in. Global setup trades VERCEL_BYPASS_SECRET for a short-lived cookie, and that secret is in none of the three 1Password vaults. Everything API-side passes against `api-dev` — fixture provisioning, Clerk session minting, seed/list/delete roundtrip, and a teardown that revoked three sessions and swept zero leaks. The environment is live; only the browser path is walled. No Section is marked DONE: §5's acceptance specs are unwritten and §1-§4 external proof is unrun. Status stays IN_PROGRESS so a pickup agent reads this as parked, not finished. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cally The previous commit recorded local proof as blocked on Vercel deployment protection. It is not, and leaving that on the record would send a pickup agent hunting a secret they do not need. Setting BASE_URL to the deployed dashboard routes the browser at a host behind Vercel Single Sign-On, which answers 302 to vercel.com/sso-api — hence three browser-side failures. Omitting it makes the config build and serve the app itself while the API still points at api-dev. Run that way, preflight passes 13 of 13, teardown revokes its sessions, and the sweeps find nothing leaked. VERCEL_BYPASS_SECRET is needed only for driving the deployed dashboard. The canonical local invocation is now recorded in the spec. Parking stands, but as a priority call rather than a blockage: M160_001 goes first, and this resumes with no further environment work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found live during M136_001: `agentsfleet login` reports success and the credential is dead fifty-two seconds later, because what reaches disk is the session token recovered from the handshake. The CLI carries no Clerk SDK and cannot refresh one, so no renewal path exists or ever could. Two layers. The Clerk `api` template is set to sixty seconds where the dashboard's own carve-out comment documents roughly fifteen minutes — operator configuration, and Indy's. And even fifteen minutes is the wrong shape for a terminal, which is this spec. The first draft persisted a tenant API key and was rejected in adversarial review before commit: `core.api_keys` binds tenant_id NOT NULL with created_by as free text, so a terminal would have authorised as the whole tenant and attribution would have collapsed to a string. No user-scoped credential exists to extend, so §1 adds one — one live per machine, enforced by a partial unique index rather than by store discipline. §3 and §4 are not optional extras: both answer failures a durable credential creates and a sixty-second one masks — credentials accumulating live on every re-login, and a deployment mismatch that stops self-correcting once the token no longer expires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A durable credential can be handed to a colleague by the account holder, who approves in their own browser and forwards the six-digit code. Every control fires correctly, because the person consenting is the person sharing — so it cannot be closed cryptographically and this spec does not pretend otherwise. Today's sixty-second token is incidentally the anti-sharing control, and removing it is a commercial consequence worth having on the record. The answer is attribution, not enforcement: three last_used_* columns overwritten in place on the update the authenticate path already performs. One credential stays one row, so "one seat, two machines, same hour" is a query rather than a mystery. No concurrency limit, no seat counting — that is a billing decision, better taken with the data than ahead of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves the durable-credential workstream out of pending so implementation can begin on its own branch. - Status: PENDING -> IN_PROGRESS - Branch: feat/m160-durable-credential - Test Baseline: unit=3512 integration=589, from `make _lint_zig_test_depth` at the branch point, so VERIFY's Test Delta row has something to compare against. No implementation in this commit — CHORE(open) lands the four transitions before any code, per the deterministic lifecycle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…machine The platform issues exactly one durable credential today and it belongs to a tenant, so a terminal holding one acts as the whole tenant and the audit trail records a free-text string where a person should be. This adds the missing primitive. user_id is a foreign key, the deliberate inverse of 240_api_keys' choice. That table keeps a plain string so an automation key outlives the admin who minted it — erasing a departed admin must not break nightly jobs. A personal credential inverts the requirement: if the human is erased, every terminal holding their credential must stop, or offboarding is theatre and a credential shared with a colleague outlives the account it belongs to. One live credential per machine is enforced by a partial unique index rather than by store discipline, so a broken revoke-then-mint ordering fails loudly instead of leaving two live credentials an operator cannot tell apart. No updated_at and no last_used_at. Revocation is the only mutation the row takes and revoked_at already records it, so updated_at would restate one fact in two columns — the problem 240's CHECK exists to police. A last_used_at provisioned for stamping that has not shipped is speculative (RULE NDC), and stamping on the authenticate path would turn the hottest indexed read in the system into a write. Attribution is a mint-time fact instead, which is sufficient: a shared credential is minted on the sharer's own machine, so it arrives as a second live row under one user_id carrying a different machine_name. The digest is plain and unsalted, which is safe only while the raw value is unguessable — so full entropy from a cryptographic source is now Invariant 9 with its own test, rather than an assumption the schema silently rests on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The value `core.cli_credentials` stores the digest of. Two properties carry weight and neither is decorative. Entropy: the row holds a plain unsalted SHA-256, which resists inversion only while its input is unguessable. So generation goes through `common.secureRandomBytes` — this project's single entropy surface, and the Zig 0.16 replacement for the removed `std.crypto.random`. Invariant 9's test asserts the property rather than the shape: a generator drawing from a clock or a counter would still produce well-formed output, so the test takes 512 draws and requires every value distinct and every body position to vary. Shape: `looksWellFormed` is checked on LOAD, not only on write, so a regression that persists a session token where a credential belongs is refused at read instead of travelling on a request. The reference implementation validates on load for the same reason (`~/Projects/oss/cli`, `internal/utils/access_token.go:16`). Also corrects this file's schema comment: 240's claim that `last_used_at` stays NULL is stale — `cmd/api_key_lookup.zig:63` stamps it on every authenticated request, mitigated with FOR UPDATE SKIP LOCKED. The decision to omit the column here stands on its own reasoning, which now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Handler-side operations on core.cli_credentials. The authenticate-path lookup is deliberately absent: src/auth/ is portability-locked and cannot reach the datastore, so digest resolution belongs in cmd/ as an injected callback, the way cmd/api_key_lookup.zig already does it for tenant keys. auth/tests.zig exists to prove that wall, so putting it here would have broken a gate rather than merely bent a convention. The digest is derived, never accepted. `mint` is the only writer and hashes the value it just generated; no path takes a hash from a caller. That matters more than it looks: if a client could supply a digest, the digest would BE the credential, and storing a hash would protect nothing. `mint` revokes this machine's live row before inserting its replacement, inside the caller's transaction. The partial unique index makes the ordering load-bearing rather than advisory — skip the revoke and the insert fails loudly instead of leaving two live credentials an operator cannot tell apart. Doing both in one transaction also means a failed insert leaves the prior credential live, rather than revoking a working terminal for nothing. `revokeById` scopes to the owner, so a credential belonging to somebody else is indistinguishable from one that does not exist and an identifier cannot be probed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Indy's call, Aug 11, 2026: M136 and M160 share a single branch and a single worktree. M160's commits are replayed onto this branch and its Branch field pointed here, since feat/m160-durable-credential is retired by the consolidation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The store had never been type-checked. Nothing in the tree calls it, and `tests.zig` referenced it with a bare `_ = @import(...)`, which evaluates a module without analysing its function bodies — so `zig build` reported success over code that could not build. Two genuine errors were hiding: - `copyListed` passed `pg.Row.get`'s error union straight into `alloc.dupe`. In `.safe` mode `get` returns `lib.TypeError!T`, so every read needs `catch return error.DbRowShape`, exactly as `cmd/api_key_lookup.zig` does — that file compiles only because `serve_boot.zig` takes its `lookup` as a function pointer. - `listForUser` built its accumulator with `std.ArrayList(Listed){}`, which Zig 0.16 rejects. Every other store uses `= .empty`. `copyListed` now takes `pg.Row` concretely rather than `anytype`, per write_zig.md's Pg Query Wrapper rule: an `anytype` row parameter is only analysed on instantiation, which is what let the mistake hide. A `refAllDecls` reference keeps every body analysed until real call sites exist, and three statement-shape tests guard invariants without needing a datastore: the list query cannot return `credential_hash` (Dimension 1.3), and both revoke statements stay owner-scoped and live-row-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`schema/250_cli_credentials.sql` creates a partial unique index and a
lookup index, and neither was registered in DECLARED_INDEXES. The repo
requires every index to name the reader that justifies it, so the
integration suite refused the schema:
UNDECLARED INDEX: core.uq_cli_credentials_user_machine_live
expected 0, found 2
The unique index gets an explicit 'no query reader' note rather than an
invented one — it is not read, it is the enforcement of one live
credential per (user, machine), and an insert that skipped the revoke
fails against it.
Found by running `make test-integration`, which had not been run on this
workstream. Integration now passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven acceptance tests asserted "not authenticated" while the spawned binary inherited the real HOME. `composeEnv` passes HOME through, and with no `AGENTSFLEET_STATE_DIR` the CLI falls back to `~/.config/agentsfleet` (`src/lib/state.ts` resolveStatePaths), so the assertions held only while the developer running them was logged out. On a machine carrying a real login they failed with the operator's own workspace id in the diff. CI has no such directory and passed throughout. `help-and-errors.spec.ts` already promised "no `credentials.json`, no live API calls" in its header; this makes the file honest. `makeEmptyStateDirSync` joins `state-dir.ts` as the inverse of the stubbed fixture already there. Folded into M160 on Indy's call: this workstream changes the persisted credential shape these specs observe, so it is the one that would otherwise merge the incoherence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two layers, because the leak had two paths. The preload now points AGENTSFLEET_STATE_DIR at an empty tmpdir when the var is unset, so in-process tests stop falling back to ~/.config/agentsfleet. This sits beside the telemetry-off default already there and shares its rationale: `runCli` reads `process.env` directly, not the `env` it is handed, because `loadCredentials()` runs first. That alone was not enough. Roughly ten sibling files write credentials into whatever state dir is current, so a shared runner-wide directory is pristine only until the first of them runs — the json-contract auth test passed alone and failed in-suite. It now takes its own empty directory and restores the prior value, matching the `withStateDir` shape already used in cli-alignment and its neighbours. CLI suite: 1429 pass, 13 skip, 0 fail; enforce-coverage PASS at line 100.00%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten test files each carried a private copy of the same scope guard —
save the env var, mkdtemp, assign, restore in finally, remove the dir.
`helpers-cli-state.ts` already owned `withFreshStateDir` for exactly this
and said so in its header; the copies had drifted anyway, and
api-key-env's was named `withFreshStateDir` too, shadowing the shared one.
Two shapes existed, so the shared module now covers both:
withFreshStateDir — call-scoped (already present)
useFreshStateDir — beforeEach/afterEach, returns the dir accessor
preserveStateDirEnv— save/restore only, for files whose tests seed their
own dir before the code reads it
Files needing extra setup delegate rather than duplicate: doctor-json and
api-key-env seed workspaces.json inside the shared wrapper. help,
cli-alignment, workspace-create and doctor-json also dropped private
`bufferStream` copies the same module already exported.
Deliberately left alone, with reason:
cli-funcfill — points the var at a FILE to test broken state
services-coverage-fillers — chmods the dir 0o500 mid-test; cleanup order
against the helper's own rm would be fragile
telemetry/{tracing,runtime,consent}, analytics.layer.fixture — save a
bundle of 5-11 env keys plus process.stdout
.isTTY; the state dir is one member, so the
helper would silently drop the rest
Net -132 lines. CLI suite 1429 pass, 13 skip, 0 fail; enforce-coverage
PASS at line 100.00%; tsc and oxlint clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pool's saturated-acquire test asserts the wait lands within SHORT_ACQUIRE_TIMEOUT_MS + ACQUIRE_TIMEOUT_OVERSHOOT_MS. At 20ms that is a 20ms wall-clock window, and it failed on a loaded machine while the pool behaved correctly — the acquire timed out and acquire_timeouts_total bumped; only the upper bound tripped. It passed three consecutive idle runs afterwards. The bound's stated purpose is catching a recompute bug that re-sleeps the full budget each iteration. That defect surfaces as a MULTIPLE of the budget, so any ceiling strictly below 2 x 50ms still catches it. 40ms puts the ceiling at 90ms: same detection, double the tolerance for scheduler noise. Going higher would blind the assertion, so this is the maximum safe value rather than a round number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three calls after the first full verification lane on the shared branch: - 9c491ce (queue/ flake) stays, approved as an in-scope inclusion; the Files-Changed table now says so rather than leaving an unexplained unrelated file in a credential Pull Request. - The never-compiled-module gap gets no new gate, no new script, no governance change. New modules carry a refAllDecls block; the existing check-test-reachability already compiles every test root, so that block is what makes them compile-checked. The repo-wide hole stays open by decision. - M136_001 is un-parked. Both workstreams finish on this branch. Also records how the store shipped two compile errors behind a green build, since that is the reason the refAllDecls convention exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The credential proves WHO; the identity provider answers WHAT. A digest resolves to a row, the row carries the owning user's oidc_subject, and an injected resolver asks the provider for that subject's scope claim, which feeds the same parseClaim the JWT path uses. No grant is authored in code and no capability is stored, so a scope edit reaches a terminal the way it already reaches the dashboard, and a narrowly provisioned collaborator is not widened by running login. agt_t is deliberately untouched. 240:7-10 records that created_by is not a foreign key precisely so a key outlives the admin who minted it; binding it to that admin's live scopes would revoke working automation when they are narrowed or offboarded. The two credentials differ because their ownership does. Adding the enum variant made three exhaustive switches fail, which is the type system listing the decisions rather than letting them default: - resolvePrincipalTenant -> resolves through the user row, like a session - approval attribution -> names the person, not a key - steer actor -> names the person, not machine Also corrects a convention I had wrong: principal.user_id carries the provider SUBJECT, not the core.users UUID — that is what the JWT path puts there and what 240 records for created_by, and downstream code resolves a user by oidc_subject, so a UUID would have resolved nothing on a route that looked authorised. UZ-AUTH-023 registered for a revoked credential, distinct from a generic refusal so the operator is told to log in again. test-auth 252/252. Depth gate unit=3532. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #597 brought b44cdb1, which solves the same leak from a parallel session and solves it better: composeEnv now defaults EVERY spawn to its own store, where my per-spec emptyEnv edits shared one directory across a file and only covered the two specs I had touched. Dropped as redundant: the AGENTSFLEET_STATE_DIR override and its module const in help-and-errors and flags-and-env, and the withFreshStateDir wrapper in json-contract, which main already brackets at file scope. Kept because they are additive rather than duplicated: the test/setup.ts preload default, which covers in-process tests main's fix does not reach, and the shared state-dir helpers ten files now migrated onto. CLI suite 1429 pass, 13 skip, 0 fail; enforce-coverage PASS at line 100.00%; tsc and oxlint clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AUTH.md said three grants are applied in code, keyed by credential source. The afc_ credential is a fourth source that deliberately breaks that pattern, and the doc had no room for it — which is why the design got re-derived several times before anyone reread this file. Records: why it differs from agt_t (240 decouples a tenant key from its creator on purpose, so it must NOT track them; an afc_ credential IS a person, so it must); what a fixed grant would have cost (a read-only collaborator silently widened on their next login — a regression the durability introduces, not one inherited); why the claim is not stored (a snapshot makes core.cli_credentials a second store of a fact Clerk owns, a webhook projection adds backfill and reconciliation to operate, an in-memory TTL cache has neither); the failure modes; and the known gap that a shared credential has no ceiling below its person's grant. Marked in-flight: the middleware and lookup exist and are tested, but the router branch and mint endpoints are not wired, so Flow 1 still persists a Clerk JWT on a deployed instance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tity provider The `afc_` credential proves who someone is and carries no grant of its own. This adds the resolver that answers what they may do, and wires the credential class into the authenticate path so both halves are live. - `clerk_scope_fetch` reads `public_metadata.scopes` over a bounded, authenticated GET. A missing or mistyped claim fails closed to an empty capability set; only an unreachable or unparseable provider is an error, so a hand-edited metadata object narrows a principal instead of failing a request open. - `clerk_scope_resolver` caches per subject for sixty seconds and serves a warm entry for up to fifteen minutes while the provider is unreachable, mirroring what the token path already does on a key-set fetch failure. The cache holds no authority: it never outlives the process, so there is nothing to backfill or reconcile. - `bearer_or_api_key` routes an `afc_` value to the credential path, ahead of the verifier check so a deployment with no identity provider configured still resolves one rather than answering 401. - The middleware registry and the serve host construct both halves. The resolver's teardown unwinds after request threads are joined. A subject the provider no longer knows resolves to no capabilities rather than to an outage, and is deliberately not cached. That path is the backstop for a `user.deleted` webhook that never arrived; when it does arrive, the account teardown cascade removes the credential row and the request never reaches here. Two test-only registry construction sites gained the new field. The middleware's tests moved to a sibling file, which the length cap required once the routing tests landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two different specs both carried the identifier M160_001: the shipped acceptance-e2e workstream in done/, and this in-flight login-credential workstream in active/. Reuse is not allowed, and the collision was actively misleading — the name no longer resolved to one spec. This workstream takes the next free workstream number. M160_002 was unused across every branch and all of git history. M136_001's four cross-references follow it; the three surviving M160_001 mentions all correctly name the shipped acceptance workstream. Indy's Aug 11 quote naming the old number stays verbatim, with the renumber recorded in the context clause beside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The resolved-not-granted section covered an unprovisioned subject but not one the provider has forgotten. That case cannot arise at login — the device flow needs a live user to approve in the browser — only after a mint, and only because the credential is durable enough to outlive its person. Names the ordinary path that closes the window (user.deleted, the teardown, the cascade on 250), the three ways it stays open, and why an empty capability set beats ERR_AUTH_UNAVAILABLE for a credential that will never work again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dential's A machine name rides the partial unique index that makes two live credentials per machine unrepresentable, and it is displayed back to an operator and printed into logs. Both the minting endpoint and the command-line client have to agree on it exactly, so it lives next to the credential's own shape check rather than being spelled twice (RULE UFS). Hostnames are the expected input, so dots belong; whitespace and shell metacharacters do not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three endpoints close the credential's life. Login spends its recovered session token on the mint and persists what comes back, so the durable credential rather than a token that dies in a minute is what reaches disk. The list is how an operator sees which terminals hold one, and therefore how a shared credential becomes visible. The revoke is what makes logout mean something on the server rather than only in a local state directory. Admission is on principal MODE, not on a required scope, and that is the load-bearing part. A tenant key carries the whole tenant grant, so it already holds every scope these routes could ask for — no scope could refuse it. It also sets a non-null user_id from its free-text created_by, so a null check alone would admit it and let an organisation mint a credential in a person's name. Only the two classes that ARE a person are admitted. A principal carries the provider's subject while the row's foreign key wants core.users(id), so every call resolves one first. That lookup is deliberately narrower than the bootstrap identity statement, which joins memberships on the owner role and needs a named workspace: a read-only collaborator satisfies neither, and a collaborator minting a credential for their own terminal is exactly the case the resolved-capability model exists to keep working. Revoking a credential that is already retired and one that was never yours answer alike, so an identifier cannot be probed for whose it is. UZ-AUTH-024 carries that refusal. The mint response goes out through the erasing writer: the raw value exists once, in that body, and nowhere afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wires the three handlers into dispatch and publishes them. Until this, the handlers existed but nothing could reach them. Adding the two Route variants broke the exhaustive switches in route_admission and route_trace — two files this change had no reason to open. That is the routing design working as intended: a new route cannot ship without an admission class and a trace policy chosen for it, and a credential-minting endpoint silently inheriting a default would have been the worse outcome. Both routes carry no required scope. None could help: a tenant key already holds every scope this family might name, so the refusal that matters is on principal mode and lives in the handler beside the ownership check. `cli-credentials` joins the REST url-shape allowlist, which is where the checker's own comment says resource policy lives — every collection noun is declared there with its justification. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eping route_admission and route_trace each listed every route in the system to say almost nothing. 76 of 80 routes took the default class; 67 of 80 took the default trace traits. Between them, 283 lines expressed 17 facts. Both now name only their exceptions and default the rest. The defaults are the conservative direction on purpose: an unlisted route falls INTO the in-flight ceiling rather than out of it, and keeps its success spans rather than being silently suppressed. An omission cannot exempt a new endpoint from backpressure or cost it telemetry. The exhaustive arms did buy something real — adding a route failed compilation until both files were touched, which is how this workstream's own two routes were caught. What it caught was an omission whose correct answer was the default anyway, and the price was appending to a 76-item list. Each file now carries a test that walks the whole Route union and asserts every route is the default unless it is one of the named exceptions. A route that silently acquires a runner budget, loses its success spans, or escapes the in-flight ceiling fails there instead of at compile time. Deliberately untouched: route_template composes its paths from shared prefix constants, so flattening it would repeat those twenty times and turn a one-line prefix change into twenty. route_scopes carries real per-method logic. route_table carries real per-route data. Those three are structure, not ceremony. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard refusing a tenant key on the credential routes is the only thing enforcing Invariant 1, and nothing exercised it. The unit tests asserted SQL shapes and an enum set — neither would notice the guard being deleted. Nine tests now call the handlers directly. The tenant-key principal is built the way tenant_api_key.zig actually builds it: `.mode = .api_key` with a NON-NULL user_id carrying the free-text created_by. That combination is the trap, and a handler checking only for a present user_id passes it. Admission is proved by the code the caller gets instead: a refused principal answers UZ-AUTH-001 at the guard, an admitted one travels on and answers the next check. Distinguishing the two is what proves admission without a datastore.
indykish
marked this pull request as ready for review
August 14, 2026 01:37
… writers Greptile P1: two concurrent logins for the same (user, machine) could both run the revoke before either insert became visible, so the loser died on the partial unique index and an otherwise valid login returned an internal mint error. mint() now takes a transaction-scoped advisory lock keyed on that pair before the revoke, so the second mint waits and cleanly revokes the first's fresh row. CLERK_API_BASE is validated once at boot rather than trusted per request: https, or plain http on loopback for the offline and boot-drain lanes. A prefix match alone was a bypass — http://127.0.0.1.attacker.example and http://localhost@evil.example both start with a loopback prefix while naming a remote host — so the loopback hostname must now terminate at end-of-string, a path, or a digits-only port. Every backend call carries the Clerk admin secret as a bearer header, so a cleartext remote base would ship it in the open; a set-but-invalid value refuses boot with ERR_STARTUP_ENV_CHECK instead of booting into an auth outage explained only by per-request warnings. The base URL and its validation moved to auth/clerk_backend_config.zig, since RULE FLL caps a Zig file at 350 lines and clerk_backend.zig had reached 355. clerk_backend re-exports the surface and stays the public API, so no call site changed. Codex found the scope resolver's stale-writer guard tied on millisecond stamps, letting a slower fetch overwrite a fresher entry; the guard is now a monotonic sequence. Both loopback test stubs gain a read timeout, an end-of-stream guard, spawn-failure teardown, and a three-attempt wake that panics loudly rather than hanging. The divergent-tenant relogin test cleans up deferred. The command-line snapshot carries apiUrl, with reasonOf, 401, and TOTAL_LEN pinned. docs/AUTH.md: the rotation runbook claimed agentsfleetd does not present CLERK_SECRET_KEY directly. It does, on two live paths, so a rotation that skips the Fly redeploy fails scope resolution closed once warm cache entries age out. CLERK_API_BASE is now documented alongside the compromise model it governs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
indykish
added a commit
that referenced
this pull request
Aug 14, 2026
The merged floor sat at 83% while `main` measures 92.40%, leaving nine points of slack the gate could never catch. Continuous Integration (CI) measures 93.70% on this branch and 98.40% on #600, so 91% clears everywhere it is graded and still leaves 1.4 points of headroom on `main`. macOS measures 87.60% on the identical suite — 870 integration tests, same kcov include/exclude patterns — because the Darwin binary carries Linux-only branches it never executes and they land in the denominator. `make test-unit-all` is therefore red on a MacBook and green in CI. Recorded here so the next reader does not chase it as a defect: production ships Linux, so the Linux figure is the truthful one, and a platform-conditional floor was considered and declined. Also drops two dead variables from `make/test.mk`: `BENCH_MODE` and `MEMLEAK_TARGET` were each defined once and read nowhere in the repository, along with the comment describing cross-compile behaviour nothing implements. `MEMLEAK_CPU` stays — `make/bench.mk` reads it.
docs/AUTH.md gains a question-to-anchor index mirroring the architecture README, so a reader jumps to the one section that answers them instead of scrolling 1000 lines. The scope catalogue now names its canon (the enum in auth/scopes.zig) and links the published user-mintable subset at docs.agentsfleet.net rather than restating it. The runner token is the fifth surface, not the sixth — there is no sixth. The architecture README's AUTH.md row still listed the fleet key as a live principal; core.fleet_keys retired at M154 §8. Its every-file-has-a-Facts-table claim is narrowed to the larger topic files, which is what is true on disk. Audited with no changes needed: README.md, docs/development.md, docs/AUTH_DEVICE_LOGIN.md, and the architecture topic files — each already brief, indexed, and linking out to docs.agentsfleet.net where user-facing coverage exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The parked §4 guard fires only when api_url is stored, so a credential carrying afc_ with api_url null slips past it and falls to the production default — the bug §4 exists to kill, surviving in the one case it can still occur. Dimension 4.6 refuses that record at read instead: isPersistable stays the token-shape mirror, a record-level isBound joins it inside tokenOf, and both readers inherit it. Tenant keys exempt, logout still revokes, existing AUTH_FAIL_MESSAGE names the repair. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 4 joins section 5 in the follow-up agent's work order on Indy's Aug 14 call. Recorded with it: section 4 replays section 3's exact delivery and needs the identifiers that run records, so it is the back half of one live pass rather than an independent slice — sections 1 through 3 are unrun in the same way, and the follow-up prompt covers the whole pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Indy's call, Aug 14 2026: the workstream does not stay in active/. It closes with an honest disposition instead — the harness, provider prerequisites and a coverage batch delivered; sections 1 through 5 deferred entire, no Dimension marked DONE and no rubric row graded, because the live pass never ran. Section 4 replays section 3's delivery, so all five move together to the follow-up agent with the work-order prompt in the PR session notes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 4 lands as one check rather than the two it was specified as. The refusal it was designed around turned out unreachable: when nothing names a target the ladder resolves TO the stored deployment, so the two cannot disagree. What remains is the leg that reached production in silence — no stored deployment and no named target — which is now refused before anything is dialled, naming all three exits. A named --api or AGENTSFLEET_API_URL always wins, on Indy's call: the operator said where to go, and a wrong pair fails at the server with a 401 that names the API URL. logout and doctor are exempt from the deployment question while still requiring a credential — a revoke must reach the deployment that minted it, and doctor is the diagnostic an operator runs precisely because their target is wrong. guardCommand owns the whole pre-action policy, so cli.ts carries no exemption lists and drops from 354 lines to 330, back under the file cap. Both specs close with this commit: M160_002 with every section DONE, M136_001 with its live proof deferred and recorded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The replace prompt said only that "an existing credential" was being overwritten, so switching from one deployment to another read identically to re-logging into the same one. It now names both when they differ, and says plainly that the old credential stays LIVE on the deployment that minted it — this machine loses its identifier on overwrite, so only that deployment's dashboard can revoke it afterwards. The prompt reads the record through snapshot rather than the token accessor, so the token and the deployment it is bound to come from one read. Three test doubles returned a snapshot whose accessToken disagreed with their own getAccessToken; the two that feed this path are corrected, and the mirror is stated in a comment so the next one does not drift. Adds deployment-binding.integration.test.ts covering the operator journeys Indy walked: logout forgets the deployment and the next bare login is production (intended, a full reset); a second login replaces token, deployment and credential id together, with the orphaned credential pinned as a known gap in the assertion itself; and a named --api reaches the deployment it names, where the credential is rejected and the output points at both `agentsfleet login` and AGENTSFLEET_API_URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Intent
An operator who logs in once keeps using the terminal — as themselves, against the deployment they logged into — until they log out.
agentsfleet loginran a correct Elliptic Curve Diffie-Hellman (ECDH) device flow and then persisted the wrong thing: the session JSON Web Token (JWT) recovered from the handshake, valid for about a minute. A terminal went stale while its operator was still reading the success message, and no renewal path exists — the Command-Line Interface (CLI) carries no Clerk Software Development Kit (SDK) and structurally cannot refresh a browser-coupled token.Two workstreams land from this branch by Indy's call (Aug 11, 2026): M160_002 (durable credential) and M136_001 (live connector proof).
Landed
M160_002 — every section, every Dimension DONE. Specification in
docs/v2/done/.§1 — a credential that names the human. New
core.cli_credentials(schema 250) with a partial unique index making two live credentials per(user, machine)unrepresentable. Only a hash is stored. Mint, list, and revoke endpoints; the credential joins the accepted principal set resolving to a user rather than a tenant.§2 — login spends its sixty seconds on something that lasts. The recovered session token buys one
POST /v1/cli-credentialscall, and the durableafc_credential that comes back is what reaches disk. The exchange completes before anything is saved, so a failed mint leaves the operator logged out and told why rather than logged in with a credential already dead.§3 — one machine, one live credential; logout ends it. A second login from the same machine revokes what it left behind. Logout revokes this terminal's credential and deliberately leaves browser sessions alone.
login --tokenand its piped-stdin seeding are gone; an unattended caller reaches the Application Programming Interface (API) withAGENTSFLEET_API_KEYalone, writing nothing to disk.§4 — a credential remembers which deployment minted it. Un-parked and landed on Indy's Aug 14 call ("yes b is fine"). Shipped as one guard rather than the two originally designed: Dimension 4.2's refusal proved unreachable, because an inferred target resolves to the stored deployment and the two cannot disagree. What remains is the leg that reached production in silence — no stored deployment and no named target — refused as
DEPLOYMENT_UNKNOWNbefore anything is dialed.guardCommandincli/src/program/auth-guard.tsowns the whole policy;logoutanddoctorstay reachable.§5 — one writer, one spelling. Folded in on Indy's Aug 13 call.
scopesis the only capability claim read; thescope-before-scopesfallback was live and mis-ordered on the authorisation path.OIDC_PROVIDER=customis refused at boot.§6 — a tenant key carries its creator's capabilities. Folded in on Indy's Aug 13 call. An
agt_tkey resolves throughclerk_scope_resolverkeyed on thecreated_bysubject the row already held, and the compiled-inTENANT_API_KEY_GRANTis removed rather than left unread.M136_001 — closed with the live proof deferred. Specification in
docs/v2/done/. §4 (replay closes the architecture marker) and §5 (the real reviewer proves the Live Wall, Playwright specs) arePARKEDon Indy's Aug 14 call, quoted in the specification header: "I cant keep in active M136_001 - move it to Done"; §4: "i want to take it later".Session notes
Two security defects found while building the exchange, both fixed here
Unbounded, self-renewing credentials. Minting accepted an existing credential as its authorisation (
.jwt_oidc, .cli_credential), andmachine_nameis client-supplied and is the uniqueness key. The mint path carries no per-user ceiling and no rate limit — both verified by reading the handler and store, not assumed. Chained, that turns one stolen credential into an unbounded supply: each mints the next under a machine name of the caller's choosing, revoking any single row leaves its siblings live, and the account holder cannot tell how many exist. Since the credential is durable, a compromise that previously expired in sixty seconds became permanent.Minting now takes a browser sign-in alone — the one step a stolen credential cannot replay. Listing and revoking still accept a credential, so a terminal can end its own access without opening a browser.
Prefix-only validation. The stored value was checked by prefix on both the load path and the mint-response decode. A value carrying trailing bytes passed. Both now match the full declared shape (
afc_plus exactly 64 lower-case hexadecimal characters), mirroringlooksWellFormedinsrc/agentsfleetd/auth/cli_credential.zig— the mechanism the spec cites Supabase'saccess_token.gofor.Decisions
agt_tas well asafc_. Per the spec's Failure Modes, a tenant key resolves as a tenant principal and is refused at the route, not at the file; only a session token is refused on load. A literal reading of Dimension 1.4 would have brokenlogin --token.UZ-AUTH-025is registered with areachable: noannotation naming the client as producer. The spec already intends client-produced registered codes (§4's deployment-mismatch refusal never leaves the process), so this follows its decided design.UZ-AUTH-021was deliberately retired byf64e20cand was not reused.machineNamederives from the hostname, not the platform.defaultTokenName()returns"macos-cli"; using it would have made every Mac claim the same row and break Dimension 3.2.emptyCredentials()— a factory, not a shared constant, becauseloadCredentialshands its fallback back by reference and a caller mutating what it read would corrupt the next reader's.Verification
make test-unit-cli— 1443 pass, 13 skip, 0 failmake test-unit-agentsfleetd— 2205 pass, 287 skip, 0 failmake test-integration— exit 0; filtered run of the new security test reports 76 pass / 0 skip, so it executes rather than skipping on an unavailable datastoremake check-version— all versions match 0.26.2gitleaks— clean; every credential-shaped fixture is built by repetition so no high-entropy literal enters the treeCaveats, stated rather than buried
--test-filter, which this Zig version rejects. The working form ismake test-integration TEST_FILTER=<substring>. The rubric rows need correcting before they are graded.docs/AUTH.mdstill says theafc_branch and mint endpoints are "not yet wired". Stale as of §1; it is in the Files Changed table and will be corrected at DOCUMENT.api_urlrung and never noticed they had become logged out.make memleakand the cross-compile rows have not been run on this diff yet; they are due before this leaves draft.🤖 Generated with Claude Code
Session notes — Aug 14, 2026 (memleak fix + adversarial sweep)
CI red on the memleak lane — root-caused and fixed. The boot-drain lifecycle
test failed
SseUnexpectedStatusafter the resolved-capability change: theseeded
agt_tkey's creator (user_lifecycle_test) has no identity-providerrecord, and the real serve boot hardcoded the vendor API base, so the offline
lane could never answer the scope resolve. The leak dump in the job log was
fallout (test erred before its drain; every daemon thread was still alive at
exit). Fix:
CLERK_API_BASEboot override resolved once beside the secret andthreaded to BOTH the scope resolver and the signup metadata writer, plus a
loopback FakeClerk in the lifecycle test answering the creator claim. The
boot-drain now proves the resolved model end to end: agt_t auth → creator claim
over real HTTP → scope closure → SSE held through SIGTERM → clean drain.
make _memleak-boot-draingreen locally (valgrind-free on macOS; CI runs thegate).
Adversarial sweep (Indy's ask, no spec): 17 findings fixed.
TypeScript: dead JWT-claims path deleted from
auth status(every credentialis opaque post-resolve; JSON envelope drops
token/credential_kind);credentials.jsonread once per command via asnapshotaccessor (was 3reads; logout's token+credential-id could span two snapshots); hydrate's
duplicated save block unified (+ dead guard removed);
reasonOfsingle-sourcedin
errors/index.ts(was 4 copies);emptyWorkspaces()kills a live drift bug(
cli.tsfallback had losttenant_id); deadgetApiUrl/authRole/extractRoleFromTokendeleted;isStringde-cloned for PR files(
lib/guards.ts);CLI_CREDENTIAL_TOTAL_LENnow pinned by a test.Zig: CLI-credential auth JOIN now returns
u.tenant_id(authoritative) ontothe principal —
resolvePrincipalTenantstops re-queryingcore.usersonevery workspace-scoped CLI request (~15 handler paths); scope-resolver cache
Mutex → RwLock (shared reads on the hot path of both resolved credential
classes); five route-matcher clones collapsed into two shared shapes;
redundant
(user_id, revoked_at)index removed from the new credentialsschema (both readers served by the partial unique index); stale "API keys
never touch Clerk" paragraph in docs/AUTH.md corrected.
Deferred (agent call, listed for Indy's veto or a follow-up slice):
pingMe→ fold into hydration's first fetch (drops one round trip;changes specced fail-loud semantics + acceptance pins).
resolveHashedCredentialgeneric to dedupe the two credential middlewares,and a shared
clerk_http.zigfor the backend fetch pair — coherent slicewith
ScopeFnreturning a parsedscopes.Set(kills the per-requestclaim re-parse) and per-subject single-flight (resolver doc updated with
the caveat meanwhile).
user_row_idtoskip
resolveUseron list/revoke.c.deploymentstays on the auth SELECT despite no current reader — thedeployment-binding dimension (§4) reads it at exactly that site.
Coverage: merged Zig line coverage 88.30% (was 87.80% at handoff; 83% gate
passes; Indy's 90% target not yet reached). Next targets by missed lines:
serve.zig 111, tenant_provider 53, serve_webhook_lookup 48,
tenant_model_entries 47, session_store_redis 45, platform_keys 42.
Test Delta: unit 3512 → 3741 (+229), integration 589 → 629 (+40) vs the
CHORE(open) baseline. CLI: 1434 pass / 0 fail. Preflight integration tests
mutation-probed red→green against live datastores.
Still owed on this branch: M160_002 §4 (deployment binding), M136_001 §5
(Playwright),
~/Projects/docschangelog branch (breaking scope-inheritancechange,
--tokenwarning rewording,auth statusenvelope change, logoutreason strings).
Decisions — Aug 14, 2026 (Indy, verbatim)
this PR is merged to implement/execute/ run the scenario via playwright, so
record in your dimension towards that effect" — recorded in the spec section
header; dimensions 5.1–5.4 are the follow-up agent's work order.
update, but any update needed in the CLI is needed" — CLI-side prose updated
instead (auth status description, README login row, orphaned role constants
removed) in b6edd41.
the api_url as well when it fails is the simple fix here." — simple fix
landed in 6b6dab5 (401/403 suggestions + auth status rejection line now
name the target API URL); the guard's full design is recorded in the spec
section as the follow-up work order.
Session notes — Aug 14, 2026 (session 3: review-fix batch landed)
Landed as
e4db078ba. Every finding from the Aug 14 adversarial chain is now committed and verified on this tree.Review outcome — 16 findings fixed (Claude adversarial: 9; Codex adversarial: 7), plus greptile's one P1 (concurrent mint race, replied on the thread). Three were closed as decisions rather than code:
api_keymode does today. Noted, not changed.auth statusJSON envelope break. Indy waived the docs/changelog obligation for this PR: "i dont need a change log update, but any update needed in the CLI is needed."Three defects found while verifying, not inherited from the review:
auth/clerk_backend.zigreached 355 lines against the 350 cap. Split perwrite_zig.md§Module Split Pattern intoauth/clerk_backend_config.zig;clerk_backendre-exports the surface and remains the public API, so none of the six call sites changed.sql.zigdoc-comment mis-attachment.LOCK_CLI_CREDENTIAL_MINThad been inserted betweenINSERT_CLI_CREDENTIALand its doc comment, so the partial-unique-index rationale documented the lock and the insert carried none. Reordered; the lock's parameters are now pinned$1::text || ':' || $2::textrather than relying on Postgresunknown-type inference.docs/AUTH.mdrotation runbook was wrong. It stated agentsfleetd does not presentCLERK_SECRET_KEYdirectly. It does —auth/clerk_scope_fetch.zigon every authenticated command-line request that misses the scope cache, andhandlers/auth/identity_events_clerk.zigon signup. A rotation that skipped the Fly redeploy would fail scope resolution closed once warm cache entries aged out. Fixed, andCLERK_API_BASEis now documented beside the compromise model it governs.Verification on
e4db078ba:cd cli && bun testzig build testmake test-integration TEST_FILTER='divergent mint snapshot'make _memleak-boot-drainmake lint-allDocs audit (Indy's punch-list ask, landed
0ff091470). Every contributor doc reviewed against the brief — brief, agent-locatable, human-skimmable, no duplication of docs.agentsfleet.net, link out where user coverage exists. Changed:docs/AUTH.md(question→anchor lookup index; scope catalogue namesscopes.zigas canon and links the published subset; runner token corrected to the fifth surface; plus theCLERK_API_BASEsection and rotation-runbook fix frome4db078ba) anddocs/architecture/README.md(fleet key retired at M154 §8 removed from the AUTH row; Facts-table claim narrowed to what's true on disk). Audited clean, untouched:README.md,docs/development.md,docs/AUTH_DEVICE_LOGIN.md, the architecture topic files. One cross-repo finding for Indy: publishedapi-reference/scopes.mdxlistsfleetkey:read/fleetkey:write, which do not exist inauth/scopes.zig— needs its own~/Projects/docsbranch, not this worktree.Tracked for Indy, not implemented —
CLERK_API_BASEthreat model as a class. The end-to-end analysis is settled for this variable: the override is read once at boot, so an attacker who can set it already owns the daemon's environment and could readCLERK_SECRET_KEYoutright. What the loopback-termination rule actually closes is the review bypass —http://127.0.0.1.attacker.exampleandhttp://localhost@evil.exampleread as localhost to a human eyeballing a manifest or a boot log, and a prefix-only check would have shipped the admin secret in cleartext on every scope-cache miss. The open question Indy owns: whether the same reasoning applies as a class to the other*_API_BASE/*_URLoverrides that ridehx.ctxin the daemon (the github and slack connector bases), and whether the test-lane override should be a build-time flag rather than an environment variable. Decision wanted class-wide, not per variable.Session notes — Aug 14, 2026 (session 4: close-out, merged)
Review is closed. Greptile raised exactly one finding across the branch's life: the P1 concurrent credential replacement race at
src/agentsfleetd/state/cli_credentials.zig:110, fixed ine4db078bawith a transaction-scoped advisory lock keyed on(user_id, machine_name)and answered in thread. Greptile's re-review of360559493scored 5/5 with "no blocking failure remains". One review thread total, zero unresolved. Continuous Integration (CI) finished green on every check — no failures, nothing skipped that carried code. Merged by Indy as92589429b.Specification state at close. M160_002
Status: DONE, every Dimension across §1–§6 markedDONE. M136_001Status: DONEwith §4 and §5PARKEDunder the quoted Indy call. Both indocs/v2/done/;docs/v2/active/is empty.Published documentation landed separately: agentsfleet/docs#173 on
chore/m160-durable-credential-changelog. It carries the Aug 14 changelog<Update>and corrects four pages that this milestone made false —cli/configuration.mdxstill told operators thelogincommand accepts--tokenand piped input,cli/agentsfleet.mdxsaid logout "revokes every active session",api-reference/introduction.mdxsaid login saves a short-lived JWT, anddocs.jsonhad none of the three/v1/cli-credentialsoperations registered. The cross-repofleetkey:*finding tracked above is fixed there too, along with the retiredagentsfleet fleet-keycommands the reference still advertised.One defect this PR shipped, fix pending.
UZ-AUTH-024's registry detail tells operators to runagentsfleet cli-credentials list— not a command in the client; only the Hypertext Transfer Protocol (HTTP) path exists. The same string also fails the docs repository's own checker on sentence length (DOC-02) and sentence count (DOC-03), so the generatederror-codes.mdxcould not lint until it was reworded. The generated page carries the corrected wording;src/agentsfleetd/errors/error_entries.zigdoes not yet, so the nextmake gen-error-codeswould reintroduce it. Needs a small follow-up PR againstmain.Cross-repository version drift, for Indy.
make gen-error-codesstamps theagentsfleetrepository version into the generated page's front matter (0.26.2), while~/Projects/docsscripts/check-documentation.pyhardcodesEXPECTED_VERSION = "0.25.0"and rejects anything else. Every regeneration now fails the docs checker until the pin moves across all 25 pages. Left as a decision rather than folded in.Greptile Summary
The PR replaces short-lived session-token persistence with durable, user-scoped CLI credentials and adds their complete mint, authentication, listing, revocation, and logout lifecycle.
core.cli_credentialsschema and database-backed credential operations.Confidence Score: 5/5
The PR appears safe to merge because the previously reported concurrent credential replacement failure is fixed and no blocking failure remains.
No blocking failure remains.
Important Files Changed
Sequence Diagram
Reviews (6): Last reviewed commit: "feat(m160_002): login names the deployme..." | Re-trigger Greptile
Context used (4)