Files
Daniel Füvesi 5a2c024e8f feat(server): first-class API key reset (self-service + org-admin) (#36)
* feat: add resetApiKey core op with audit event

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.

* feat: add self-service and org-admin API key reset routes

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.

* feat: add auth reset-key and member reset-key CLI commands

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.

* test: cover API key reset in the CLI E2E suite

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).

* docs: add reset-key routes to the OpenAPI spec

Keeps docs/openapi.json in sync with the two new routes, per the CI
freshness check (sync-openapi.ts && git diff --exit-code).

* docs: document reset-key commands in the skill and API reference

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.

* fix(cli): auth reset-key writes the config key api-client reads, honors --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.

* fix(cli): member reset-key persists the new key when an admin targets 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.

* fix(cli): resolve member reset-key's self-target identity before rotating 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.

* fix(core): wrap resetApiKeyOrgless in a transaction, document the audit-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.

* test(e2e): cover the persisted-key, --json, and admin self-target reset 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.

* fix(server): reread persisted config for daemon IPC auth

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/agent-fs#36 (review 5082653098, inline comment
on packages/cli/src/commands/auth.ts:60).

* docs: distinguish self-service key rotation from admin recovery

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/agent-fs#36 (review 5082653098, inline comment
on docs/api-reference.md:48).

* test(e2e): cover the daemon IPC credential regression against the real 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).

---------

Co-authored-by: capchase-bot <75273842+capchase-bot@users.noreply.github.com>
2026-09-02 15:43:28 +02:00

6.1 KiB

API Reference

agent-fs exposes a single HTTP API. All file operations go through one dispatch endpoint.

Base URL

http://localhost:7433

Authentication

All endpoints (except /health and /auth/register) require a Bearer token:

Authorization: Bearer <api-key>

Get an API key by registering:

curl -X POST http://localhost:7433/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com"}'

Endpoints

GET /health

Health check. No auth required.

curl http://localhost:7433/health
# {"ok":true,"version":"0.1.1"}

POST /auth/register

Register a new user. Returns user ID, org ID, drive ID, and API key. The key is shown only once — it isn't stored anywhere but the user's own config.json.

curl -X POST http://localhost:7433/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "agent@example.com"}'

agent-fs auth reset-key rotates your own key while you still hold the current one — it calls /auth/reset-key, which sits behind the same auth middleware as every other endpoint, so it cannot help if the key is genuinely lost. Recovering a lost key requires an org admin to run agent-fs member reset-key <email> on the locked-out user's behalf. Either path invalidates the old key immediately.

GET /auth/me

Get current user info with default org/drive context.

curl http://localhost:7433/auth/me \
  -H "Authorization: Bearer <api-key>"
# {"userId":"...","email":"...","defaultOrgId":"...","defaultDriveId":"..."}

ALL /mcp

MCP endpoint (Streamable HTTP transport). Accepts JSON-RPC requests from MCP clients. Stateless — each request creates a fresh MCP server instance.

curl -X POST http://localhost:7433/mcp \
  -H "Authorization: Bearer <api-key>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}'

In practice, use agent-fs mcp (stdio proxy) rather than calling /mcp directly. The proxy handles the MCP lifecycle (initialize, tools/list, tool calls) automatically.

POST /orgs/{orgId}/ops

Dispatch any file operation. The op field determines which operation runs.

curl -X POST http://localhost:7433/orgs/<orgId>/ops \
  -H "Authorization: Bearer <api-key>" \
  -H "Content-Type: application/json" \
  -d '{"op": "write", "path": "/hello.md", "content": "# Hello"}'

An optional driveId field targets a specific drive. The drive must belong to {orgId} — a driveId from another org returns 404 NOT_FOUND, the same response as a nonexistent drive, so drive IDs cannot be probed across tenants. Each op requires a minimum drive role (see Access control).

See the OpenAPI spec for the full schema of each operation, or browse it interactively:

  • Live endpoint: GET /docs/openapi.json (when server is running)
  • Static file: docs/openapi.json (committed to repo)

Import either into Swagger Editor or Scalar for interactive exploration.

Raw file bytes

Use the raw file route for binary-safe uploads and downloads:

curl -X PUT http://localhost:7433/orgs/<orgId>/drives/<driveId>/files/assets/logo.png/raw \
  -H "Authorization: Bearer <api-key>" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @logo.png

curl http://localhost:7433/orgs/<orgId>/drives/<driveId>/files/assets/logo.png/raw \
  -H "Authorization: Bearer <api-key>" \
  -o logo.png

The raw route preserves bytes exactly. Text indexing runs only for valid, indexable UTF-8 payloads.

PUT /raw requires the editor role (or better) on the target drive — viewers get 403 PERMISSION_DENIED, matching the JSON write op. GET /raw is viewer-accessible. As with the ops route, the driveId in the path must belong to the orgId in the path; mismatches return 404.

Access control

All routes authenticate via API key and authorize against explicit memberships:

  • Strict drive membership — a drive is only visible and accessible to users with an explicit drive membership row. Creating a drive grants the creator an admin membership automatically.
  • Per-op role gates — read ops (ls, cat, search, signed-url, ...) require viewer; write ops (write, edit, append, rm, mv, cp, revert, comment-add, ...) require editor; reindex requires admin.
  • Member management is admin-only — inviting, listing, updating, and removing org members requires org admin. Drive member routes require drive admin or admin of the owning org.
  • No existence oracle — requests that reference an org or drive you have no access to return 404, identical to the response for IDs that don't exist.
  • Scoped comment IDs — comment IDs only resolve within the org/drive context they were created in; cross-tenant IDs return 404.

Signed URLs are bearer secrets

The signed-url op is viewer-accessible and RBAC is checked only at generation time. The returned URL is a presigned S3 URL: it requires no authentication and grants download access to anyone who has it until it expires (default 24h, max 7 days). Treat signed URLs like bearer tokens — don't log them, don't post them anywhere you wouldn't post a credential, and use the shortest expiry that works (expiresIn).

Operations

All 26 operations are dispatched through POST /orgs/{orgId}/ops. Each expects {"op": "<name>", ...params}.

Category Operations
Content write, cat, edit, append, tail
Navigation ls, stat, tree, glob
File Management rm, mv, cp
Version Control log, diff, revert
Search grep, fts, search
Maintenance recent, reindex
Comments comment-add, comment-list, comment-get, comment-update, comment-delete, comment-resolve

For parameter details, see the OpenAPI spec.