fix(cc-config): one account is one profile, and rename it in place

Four defects, all from one design mistake: a profile's display name and its
filename were the same string.

- Saving the logged-in account made a SECOND profile of an account another
  profile already held. Both then reported themselves active, so there was
  nothing to switch between. `POST /profiles` now refuses `EDUPLICATE` and
  names the profile that already holds the account. Re-saving into the
  account's own profile stays allowed - that is the token-refresh path.
- Worse, an accidental duplicate could not be cleaned up: two profiles of one
  account are both active, and the delete guard refused any active profile, so
  neither was ever deletable. The guard now blocks only the LAST saved copy of
  the live account, which is the actual credential-loss condition it exists
  for. The trash button had to follow, or the server would allow what the UI
  hid.
- A name could not contain spaces or diacritics, because it had to be
  filename-safe. A profile now carries `name` (internal identifier, still the
  filename, still what every route takes) and `label` (what the user reads and
  edits: 1-60 free-form characters, control characters rejected, stored
  trimmed, unique case-insensitively). A label never touches a path, which is
  what makes "Tài khoản chính" a legal name.
- Naming happened up front in a "save current account as" field. That field is
  gone. The server derives the identifier, the save button lives on the live
  account's card and appears only while it is unsaved, and the label is edited
  by clicking it (Enter saves, Escape cancels).

Renaming rewrites the label in place and deliberately does NOT move `name`, so
it cannot half-fail and leave a profile unreachable. Profiles written before
labels existed report `label` equal to their `name`, so nothing needs migrating.

Adds `PATCH /api/cc-config/profiles`; `POST` now takes an optional `name`.

Verified: server 1431/1431 (11 new), client 459/459, tsc clean. Also exercised
end to end against a COPY of the real profile store on this machine: the
duplicate deleted, the last copy still refused, a Vietnamese label accepted,
and the stored access/refresh tokens and 0600 mode intact after a rename.
This commit is contained in:
2026-09-07 16:29:11 +07:00
parent 4bc77ded17
commit 11dcc04ac4
12 changed files with 560 additions and 102 deletions
+8 -3
View File
@@ -1581,7 +1581,8 @@ GET /api/cc-config/memory
GET /api/cc-config/file?path=<absolute-path>
GET /api/cc-config/backups[?scope=&type=]
GET /api/cc-config/profiles
POST /api/cc-config/profiles Body: { name }
POST /api/cc-config/profiles Body: { name? }
PATCH /api/cc-config/profiles Body: { name, label }
POST /api/cc-config/profiles/activate Body: { name }
DELETE /api/cc-config/profiles Body: { name }
PUT /api/cc-config/file Body: { scope, type, name?, content }
@@ -1608,9 +1609,13 @@ POST /api/cc-config/marketplaces/action Body: { name, action: update|remove }
**Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each rewritten only if it already exists, key-merged so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` also stays put: those are the machine's MCP server tokens, not account identity.
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`). `name` must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$` (it is the filename).
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
The dashboard **cannot log an account in** — only `claude auth login` mints tokens. Adding a profile is therefore always "log in from a terminal, then `POST /profiles` to snapshot what the CLI wrote". `POST /profiles/activate` re-snapshots the outgoing account into its own profile first (reported as `savedOutgoing`), so tokens Claude Code refreshed since the last save are not lost, and returns `{ ok, name, savedOutgoing, written }`. Errors: `EBADNAME` (400), `ENOTFOUND` (404 — no such profile), `EBADJSON` (409 — a config file on disk is unparseable; nothing is written), `ENOAUTH` (409 — nobody is logged in to snapshot), `EUNSAVED` (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), `EACTIVE` (409 — refusing to delete the active profile). Mutations broadcast `cc_config_changed` with `type: "profile"`. A switch only takes effect for **new** Claude Code sessions, and a session still running will rewrite the config on exit — close them first.
A profile has two names, and the split is what lets a profile be called `Tài khoản chính`. `name` is the internal identifier every mutation route takes, and it is the filename, so it must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`; callers normally never choose it, because `POST /profiles` derives it from the account. `label` is the display name the user edits with `PATCH /profiles`: free-form 1-60 characters, spaces and diacritics allowed since it is never used as a path, stored trimmed, rejected for control characters (`EBADLABEL`), and unique across profiles case-insensitively (`ELABELTAKEN`). A rename rewrites the label in place and **does not move `name`**, so it cannot half-fail and leave a profile unreachable. Profiles written before labels existed report `label` equal to their `name`.
One account is one profile: `POST /profiles` refuses with `EDUPLICATE` when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report `active: true`, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label.
The dashboard **cannot log an account in** — only `claude auth login` mints tokens. Adding a profile is therefore always "log in from a terminal, then `POST /profiles` to snapshot what the CLI wrote". `POST /profiles/activate` re-snapshots the outgoing account into its own profile first (reported as `savedOutgoing`), so tokens Claude Code refreshed since the last save are not lost, and returns `{ ok, name, savedOutgoing, written }`. Errors: `EBADNAME` / `EBADLABEL` (400), `ENOTFOUND` (404 — no such profile), `EBADJSON` (409 — a config file on disk is unparseable; nothing is written), `ENOAUTH` (409 — nobody is logged in to snapshot), `EDUPLICATE` (409 — another profile already holds this account), `ELABELTAKEN` (409 — another profile already uses that label), `EUNSAVED` (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), `EACTIVE` (409 — refusing to delete the **last** saved copy of the live account; a redundant copy is deletable, which is what makes an accidental duplicate cleanable). Mutations broadcast `cc_config_changed` with `type: "profile"`. A switch only takes effect for **new** Claude Code sessions, and a session still running will rewrite the config on exit — close them first.
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.