fix(cli): send api-keys via X-API-Key in client.New + help surface #7

Merged
hzhang merged 1 commits from fix/apikey-auth-header-and-help-introspect into main 2026-05-26 11:48:21 +00:00
Owner

Why

Manager (role mgr) reported in fabric channel 32a7da92 on 2026-05-26 that hf cli had no project-create permission. Real cause was two-layered:

  1. passmgr.GetToken() reads hf-token from secret-mgr; provision-hf-accounts.sh historically stored at hf-access-token — separate PR (orion/HangmanLab.Server.T2#?) fixes that.
  2. Even with the right key, client.New always sent the api-key as Authorization: Bearer <hex>. The HF backend HTTPBearer middleware expects JWT shape there and rejects hex. d2b83ad on the backend added a Bearer-fallback that tries the string as an api-key, which masks the cli bug against current prod — but help/surface.go:detectPermissionState calling /auth/me/permissions was still failing on older paths, leaving Known:false and rendering only the always-permitted user.* subset.

What

  • client.New auto-detects token shape: eyJ prefix + two dots ⇒ JWT (Authorization: Bearer), anything else ⇒ api-key (X-API-Key). Empty token sets neither header.
  • internal/help/surface.go:loadPermissionState switches to client.NewWithAPIKey explicitly so command-discovery does not depend on the heuristic at all.
  • Adds internal/client/client_test.go with three table-driven tests covering both header paths, empty token, and NewWithAPIKey precedence.

Verification

  • go test ./internal/client/ — 3/3 pass
  • Sim rebuild of harborforge-backend to prod commit d2b83ad, then wire-level capture against fake backend:
    • api-key d7a774…X-Api-Key: d7a774…
    • JWT-shape eyJ…Authorization: Bearer eyJ…
  • HF_TEST_MODE=1 ./hf project list --token <mgr-api-key> returns the project list (200 OK).

🤖 Generated with Claude Code

## Why Manager (role `mgr`) reported in fabric channel `32a7da92` on 2026-05-26 that `hf` cli had no project-create permission. Real cause was two-layered: 1. `passmgr.GetToken()` reads `hf-token` from secret-mgr; provision-hf-accounts.sh historically stored at `hf-access-token` — separate PR (`orion/HangmanLab.Server.T2#?`) fixes that. 2. Even with the right key, `client.New` always sent the api-key as `Authorization: Bearer <hex>`. The HF backend `HTTPBearer` middleware expects JWT shape there and rejects hex. d2b83ad on the backend added a Bearer-fallback that tries the string as an api-key, which masks the cli bug against current prod — but `help/surface.go:detectPermissionState` calling `/auth/me/permissions` was still failing on older paths, leaving `Known:false` and rendering only the always-permitted `user.*` subset. ## What - `client.New` auto-detects token shape: `eyJ` prefix + two dots ⇒ JWT (`Authorization: Bearer`), anything else ⇒ api-key (`X-API-Key`). Empty token sets neither header. - `internal/help/surface.go:loadPermissionState` switches to `client.NewWithAPIKey` explicitly so command-discovery does not depend on the heuristic at all. - Adds `internal/client/client_test.go` with three table-driven tests covering both header paths, empty token, and `NewWithAPIKey` precedence. ## Verification - `go test ./internal/client/` — 3/3 pass - Sim rebuild of `harborforge-backend` to prod commit d2b83ad, then wire-level capture against fake backend: - api-key `d7a774…` → `X-Api-Key: d7a774…` - JWT-shape `eyJ…` → `Authorization: Bearer eyJ…` - `HF_TEST_MODE=1 ./hf project list --token <mgr-api-key>` returns the project list (200 OK). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
zhi added 1 commit 2026-05-26 11:44:04 +00:00
passmgr.GetToken returns an api-key in padded-cell mode (provisioned by
scripts/provision-hf-accounts.sh via 'hf user reset-apikey'), but every
call site funneled that through client.New which sent it as a
'Authorization: Bearer <hex>'. The HF backend's HTTPBearer middleware
expects JWT shape there and rejects hex strings as 'Could not validate
credentials'. The d2b83ad backend fix added a Bearer-fallback that tries
the value as an api-key, which masked the issue against current prod;
older backends or any future change in that fallback still 401.

Two changes:
- client.New auto-detects shape: 'eyJ'-prefix + two dots == JWT (Bearer),
  anything else == api-key (X-API-Key). Empty token sets neither header.
- internal/help/surface.go's loadPermissionState (called by hf --help
  introspection) switches to client.NewWithAPIKey explicitly so the
  command-discovery path doesn't depend on the heuristic at all. When
  that path failed silently (Known:false), agents would see only the
  always-permitted commands ('user.*', 'agent.status', 'config',
  'health', 'version') and conclude they had no project permission.

Adds internal/client/client_test.go covering both header paths plus
empty-token, isLikelyJWT cases, and NewWithAPIKey precedence.

Verified end-to-end in sim against a rebuilt hf-backend matching prod
(commit d2b83ad): cli with --token <api-key> sends X-Api-Key header,
backend returns 200 on /projects + /auth/me/permissions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hzhang merged commit 1c9e90b033 into main 2026-05-26 11:48:21 +00:00
hzhang referenced this issue from a commit 2026-05-26 11:48:22 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: zhi/HarborForge.Cli#7