Skip to content

feat(server): first-class API key reset (self-service + org-admin) - #3

Open
capchase-bot wants to merge 14 commits into
mainfrom
feat/api-key-reset
Open

capchase-bot wants to merge 14 commits into
mainfrom
feat/api-key-reset

Conversation

@capchase-bot

@capchase-bot capchase-bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Summary

This adds a first-class way to reset an agent-fs API key, replacing the current "re-register" workaround — keys are shown exactly once at registration today and there was no way to rotate a leaked key, or recover a member's lost key, without creating a brand-new user. auth reset-key is self-service rotation (you still hold the current key); recovering a genuinely lost key is the org-admin path (member reset-key), since /auth/reset-key sits behind the same auth middleware as every other endpoint.
Two new endpoints: POST /auth/reset-key lets an authenticated user rotate their own key, and POST /orgs/{orgId}/members/{userId}/reset-key lets an org admin rotate a member's key (403 for non-admins acting on the route, 404 when the target is not a member of that org).
Two new CLI commands: agent-fs auth reset-key and agent-fs member reset-key <email>, both with --json support; the admin command never writes the target member's new key into the admin's own local config.
Every reset inserts an api_key_reset audit event (method: "self" or "admin") in the same transaction as the key-hash update, via the new resetApiKey core op in packages/core/src/identity/users.ts (plus a small resetApiKeyOrgless fallback for the defensive case of a self-reset by a user with zero orgs).

Test plan

Run from the repo root on this branch:

bun install --frozen-lockfile
bun run typecheck

Expected: tsc --build exits 0, no errors.

bun run build
bun run test

Expected: 524 pass / 0 fail / 57 skip.

bun run scripts/e2e.ts "bun run packages/cli/src/index.ts --" --local-only

Expected: 128/128 passing (15 skipped, all Docker/MinIO-gated) — this is the exact CI e2e job.

bun run scripts/sync-openapi.ts
git diff --exit-code docs/openapi.json

Expected: clean, no diff (OpenAPI spec already committed up to date).

find packages/*/src -maxdepth 1 -name "*.js"

Expected: no output (stale-.js guard).

Redeploy surfaces

  • Sandbox backend agent-fs.sandbox.capchase.com — needs a redeploy to pick up the two new routes (/auth/reset-key, /orgs/{orgId}/members/{userId}/reset-key) and the new resetApiKey core op. See DEPLOYMENT.md.
  • live/ and landing/ (Vercel) — no functional change in this PR (CLI/server-only), so no redeploy is required for these, but they auto-deploy previews on this PR regardless per the standard Vercel Git integration.

Plan

Implements the plan Daniel approved in thoughts/6557a439-15b8-4c55-a639-638626f4983e/research/2026-09-01-agent-fs-api-key-reset.md.

Note

This PR replaces desplega-ai#35, which was opened by mistake against the upstream desplega-ai/agent-fs instead of Capchase/agent-fs. Same branch, same commits, correct base repo.

Lost/leaked API keys have no recovery path today — reissuing one
required a direct DB write. resetApiKey rotates the hash and records
an api_key_reset event (self vs admin method) so the action is
auditable.
POST /auth/reset-key lets a user rotate their own key. POST
/orgs/:orgId/members/:userId/reset-key lets an org admin rotate a
member's key when they've lost it, using the same resetApiKey op so
both paths get the audit trail. A user with zero orgs (should not
happen given personal orgs are auto-created) falls back to a raw
rotation with no event, since there's no orgId to attach it to.
Gives users and org admins a way to rotate a lost/leaked key from the
CLI instead of needing direct DB access. Self-reset saves the new key
to config like registration does; the admin path never writes the
member's key to the admin's own config.
Self-reset and admin-reset scenarios against a real daemon: old key
401s, new key works. Also updates the stale comment on the FUSE
auth-expired test, which still fakes revocation by corrupting the
local config — now that resetApiKey exists, the comment explains why
that test doesn't just call it (it's exercising the mount's read path
for an already-invalid key, not the reset op).
Keeps docs/openapi.json in sync with the two new routes, per the CI
freshness check (sync-openapi.ts && git diff --exit-code).
Adds both CLI commands to the SKILL.md command tables (plus trigger
phrases so the skill fires on "lost my api key" style prompts), and
tells registration-flow readers in docs/api-reference.md how a lost
key is recovered.
…rs --json

api-client.ts resolves config.apiKey before config.auth.apiKey, but
reset-key only ever wrote auth.apiKey. A user with a persisted
top-level apiKey (e.g. from the dashboard onboarding path) got a
stale, now-invalid key after a successful reset. Write both fields so
whichever one the client reads holds the fresh key.

Also honor the root --json flag like sibling commands (member
reset-key, org list, ...) do.
… their own email

An org admin resetting their own email via 'member reset-key' had
their server-side credential invalidated with no local persistence,
locking them out of their own session. Detect the self-target case
(compare against client.getMe()) and save the fresh key to config the
same way auth reset-key does.
…ting the key

client.getMe() was called after the reset POST, using the caller's own
key — but a self-target reset just invalidated that key server-side,
so the identity check itself 401'd. Move the getMe() call before the
reset so it still has a valid credential.

Caught by the new e2e admin-self-target-reset test.
…it-event gap

Match resetApiKey's atomicity guarantee for the orgless fallback path.
Recording an api_key_reset event like the org-scoped path does isn't
possible here: events.orgId is a NOT NULL FK to orgs, and this path
by definition has no orgId to attach one to (blocked — see PR
discussion, no schema change made unilaterally).

Adds direct core-layer coverage: no CLI/HTTP path can reach this state
(every registration auto-creates a personal org whose last admin can
never be removed), so the test simulates it by stripping org
membership after creation, same precondition the /auth/reset-key
route checks for.
…et paths

Adds three regression cases that would have caught the config-field
mismatch (auth.ts) and self-lockout (member.ts) bugs fixed in prior
commits on this branch:
- auth reset-key --json returns structured output
- reset-key updates whichever config field api-client.ts actually
  reads (simulates the dashboard-onboarding shape where only the
  top-level apiKey field is populated)
- an admin resetting their own email via member reset-key persists
  the fresh key instead of locking themselves out

Also clears AGENT_FS_API_KEY/AGENT_FS_API_URL in testEnv()'s base env,
extending the existing stray-host-env defense (previously only
covered AGENT_FS_DEFAULT_ORG_ID and friends): an agent-swarm worker's
own real agent-fs credentials were leaking into runRaw() calls,
pointing config-only-auth tests at production instead of the
ephemeral test daemon.

@capchase-bot capchase-bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE-equivalent

Reviewed at head 10579771. CI is green (run); the added coverage addresses the original five review requests.

  1. Verified fixed — stale persisted credential. auth reset-key now writes both apiKey and auth.apiKey, matching the precedence in ApiClient. The persisted-config regression, including a follow-up auth whoami, is in scripts/e2e.ts.
  2. Verified fixed — admin self-target reset. member reset-key resolves the caller before rotation and persists the returned key only for self-targets. The end-to-end self-target test verifies a subsequent authenticated command in scripts/e2e.ts.
  3. Partially fixed — acceptable — orgless reset. events.orgId is a non-null FK, so this state cannot produce the normal event without a schema/design change. The fallback is now transactional and documented in users.ts. Supported flows make the state unreachable: creation makes a personal org (users.ts), and removal from one is rejected (orgs.ts). The direct DB-level regression test is therefore the appropriate way to cover the defensive fallback (identity.test.ts).
  4. Verified fixed — --json. auth reset-key reads the root option and returns only JSON; its E2E assertion is in scripts/e2e.ts.
  5. Verified fixed — test gaps. The persisted-key and self-target E2Es above cover the black-box paths. The only orgless test requires deliberately breaking the database invariant, which is covered at the core boundary rather than fabricated through a CLI/HTTP flow.

Local targeted test/typecheck could not run because this checkout lacks installed dependencies (drizzle-orm/bun-sqlite and bun-types); this is an environment issue, not a PR failure. GitHub CI ran successfully at this exact SHA.

The IPC resolveApiKey closure read the `config` object captured once at
process startup, so a self-reset (which rewrites config.json in place)
left every FUSE mount authenticating with the revoked key until the
daemon was restarted. getConfig() already does a fresh disk read with
no cache, so rereading it per call is enough.

Adds a unit regression test that runs both the frozen-at-startup and
the reread-per-call resolver against one running IPC server to prove
the fix needs no daemon restart.

Addresses desplega-ai#36 (review 5082653098, inline comment
on packages/cli/src/commands/auth.ts:60).
docs/api-reference.md claimed a lost key is recoverable via `auth
reset-key`. That endpoint sits behind the same auth middleware as
every other route, so an owner who genuinely lost their key gets 401
and can't call it. Only `member reset-key` (org admin) recovers a
locked-out user; `auth reset-key` is rotation while the current key
is still available.

Checked skills/agent-fs/SKILL.md and the CLI --help text for both
reset-key commands — both already describe rotation accurately, no
change needed there.

Addresses desplega-ai#36 (review 5082653098, inline comment
on docs/api-reference.md:48).
…l socket

The unit test in packages/server/src/ipc/__tests__/server.test.ts
reproduces the resolver pattern in isolation. This adds the same
regression at the e2e level, against the real daemon process's Unix
socket (the one the Rust FUSE helper speaks to): register, connect
over IPC, reset via the real CLI path, then reconnect on the same
running daemon and confirm the rotated key authenticates — no
restart in between.

Needs msgpackr to speak the daemon's wire protocol from scripts/e2e.ts;
it's already a direct dependency of packages/server and packages/cli,
just not resolvable from the repo root until now.

Per CLAUDE.md's release checklist (IPC contract change).
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.

2 participants