fix(usage): only sign in to and remove profile dirs Paradev created

TS
6h ago
+1600-105

What & why

Fixes #9170.

Settings → Usage → Add account builds the new profile's home from the profile name: ~/.codex-<name> or ~/.claude-<name>. The name started as work, and an empty name also became work. The command then signed in to whatever was there: mkdir -p for Codex and API-billed profiles, no mkdir at all for a Claude subscription. Current codex login logs out and revokes the home's stored login before the browser step, so a name that matched an existing home ended that login, even when the sign-in was cancelled. claude auth login replaces the dir's login.

Remove… deleted the whole dir (rm -rf), plus the Claude dir's Keychain items. Discovery lists signed-in homes under ~ and ~/.config whoever made them, so Remove also deleted homes the user made by hand, or that another tool still uses (for example a credential router that reads the same CLI homes). The only guards were "under $HOME" and "not a system default".

Add account

  • No default name, and no work fallback. Sign in stays disabled until a name is entered. If the name's home is already a discovered login, the dialog says "~/.codex-<name> already exists. Choose another name." and keeps Sign in disabled. A name with no letter A–Z, digit or hyphen (for example 仕事) says "Use letters A–Z, digits, or hyphens." For a remote host, the copy-and-paste command is hidden in all these cases, and until the dialog has this opening's list of logins.
  • The command is one sh -c '…' script with no single quote inside. No alias or function from the user's shell config reaches it, so an alias mkdir='mkdir -p' or a mkdir function can't turn the new-dir check into a no-op, and every login shell, fish included, passes it to sh unchanged. The script creates the home with command mkdir and writes a .superset-created marker (the agent id) before the login runs. It reuses an existing dir only when the dir has that marker and still holds no login, so Try again keeps working. Anything else exits 1 before the CLI starts, with Paradev: <dir> already exists. Choose another profile name. in the terminal. The check runs on the host, so it also covers the command shown for remote hosts. addAccountCommand throws on a name that isn't a slug.
  • "Holds a login" uses the files discovery reads: auth.json for Codex; .claude.json with oauthAccount, or .credentials.json, for Claude; .superset-api-billing for both.
  • $HOME stays unexpanded. CLAUDE_CONFIG_DIR gets the same absolute path as before, so Claude's Keychain item still matches what agent launches use.
  • The API-key variants get the same guard. .superset-api-billing is still written only after the login exits 0. sh has no read -s, so the Codex key prompt turns echo off with stty and turns it back on when the prompt ends or is interrupted. The key still goes to codex login --with-api-key on stdin, never in the command text.
  • A new profile's sign-in is taken only from a dir its own command created. usage.logins now reports createdByParadev for each dir (its .superset-created names the agent), and findCompletedLogin with newProfile refuses a login at a path the baseline already held, or in a dir without the marker. The dialog stays mounted while closed, so each opening now polls its own query: a login that another tool made while the dialog was closed is part of the baseline, and so is a home that was removed in between. While nothing is signed in at the new profile's dir, the latest poll becomes the baseline, so a login that lands there afterwards is detected wherever the command ran. A host older than createdByParadev leaves it out, and the baseline rules still apply to it.

Remove

  • removeCodexHome and removeClaudeProfile delete only a dir whose .superset-created names the agent. The check runs before any Keychain deletion or rm.
  • Any other dir is refused with PRECONDITION_FAILED: "Paradev only removes profiles it created and has no record of creating <dir>, so nothing was deleted. Delete the folder yourself if you no longer need it." It is not an INTERNAL_SERVER_ERROR, so the Sentry middleware does not report it.
  • usage.quota reports createdByParadev for each account. The card's … menu offers Remove… only for such a profile; for any other it lists Remove… as unavailable, with "Paradev only removes profiles it created." under it, so the confirm dialog never promises a deletion that would be refused. A host older than the field reports nothing, so its profiles show the same, and that host's Remove, which doesn't check the marker, is no longer reachable from the menu.
  • The path and system-default guards are unchanged. removeClaudeProfile takes the Keychain deleter as an optional second parameter, so the test can check the order on any platform.
  • The docs say which profiles Remove leaves in place, and that on macOS a Claude Code profile deleted by hand leaves its Claude Code-credentials-… Keychain items behind.

Trade-off: profiles added by earlier builds have no marker, so Remove is unavailable for them too. I did not count .superset-api-billing as proof of creation for older API-billed profiles, to keep one rule; I can add that if you prefer.

tRPC compatibility: the only contract changes are two new output fields: createdByParadev on each usage.logins entry, and an optional createdByParadev on usage.quota accounts. An older desktop ignores both. This desktop treats a missing field as unknown: Add account falls back to the baseline rules, and Remove stays unavailable. usage.removeAccount keeps its input and output; the new refusal throws PRECONDITION_FAILED, and the existing error cases keep their codes.

Not in this PR: provisioning (prepareAccount, setDefaultAccount) still shares config into the home it is given; this PR only stops Add from handing it a home that already existed. Switch sign-in is an explicit re-login of an existing home and is unchanged.

How I tested it

Tests first, then the change. Everything ran in Docker on oven/bun:1.4.3 (the version the repo pins), with the tree streamed in by git archive and no network; the image's dependencies came from bun install --frozen --ignore-scripts, and I added zsh and fish from Debian so the shell tests below don't skip.

New tests on the previous head of this PR (290f135): in the desktop usage folder, 18 fail and AccountMenuItems.test.tsx can't load its module; in the host-service usage folder, usage.logins fails and profiles.test.ts can't load (isCreatedProfile is new).

  • addAccountCommand.test.ts (13 fail): the parse check, the slug check, the three Codex API-key cases under sh (dash has no read -s), the key on stdin, the paste into an interactive bash and zsh whose rc aliases mkdir to mkdir -p or defines a mkdir function (there the old command doesn't refuse the signed-in home), and all three fish cases (fish can't parse the old command).
  • AddAccountDialog.test.tsx (4 fail): the command shown before this opening's logins loaded, a login made while the dialog was closed taken as the new profile, a login in a dir the command didn't create taken as the new profile, and adding the same name again after Remove never detected.
  • findCompletedLogin.test.ts (1 fail): a login in an unmarked dir accepted as a new profile.
  • The new tests that pass on the previous head guard behaviour that must not change: a new profile's first sign-in is found and prepared, Try again after a cancelled sign-in reaches the CLI, a token refresh in a home that was already signed in isn't taken as the new profile, the name resets between openings, and the local Sign in stays off for an empty or taken name.

With the change:

  • bun test in apps/desktop on src/renderer/routes/_authenticated/settings/usage: 98 pass, 0 fail. In packages/host-service, on src/trpc/router/usage: 161 pass, 0 fail. Each of the three new commits passes these folders and the typecheck on its own.
  • bun x turbo typecheck --filter=@superset/desktop --filter=@superset/host-service (with ^build and ^build:types): passes.
  • biome check . over the whole repo (12,111 files): no diagnostics.
  • bun run check:i18n passes and leaves the committed catalogs unchanged. The two new messages are translated in all 16 locales.
  • Full suites of both packages, on this branch and on the previous head: the branch fails a subset of the tests that fail on the previous head, compared by test name. Branch: desktop 9,029 pass and 44 fail, host-service 2,178 pass and 521 fail. Previous head: desktop 9,002 pass and 44 fail, host-service 2,170 pass and 525 fail. The failures are git/worktree, gh, terminal and worker-bundle integration tests that this container can't run; the 4 extra on the previous head are timing-sensitive tests that passed there in an earlier run.
  • After the last full run I fixed the formatting of one test file (usage.test.ts), then reran biome check . and both usage folders: clean, and all pass.

Shells. The shell tests run whatever is installed and skip the rest. In the container they ran under dash (as sh), bash, zsh 5.9 and fish 4.0. I also drove the Codex key prompt in a pseudo-terminal, with dash and with bash as sh: the key isn't echoed, reaches the CLI on stdin, and echo is back on afterwards; Ctrl-C at the prompt exits 130 with echo back on, and the CLI never runs. On macOS I ran the four generated commands with stub CLIs (fresh HOME, PATH limited to the stubs and /usr/bin:/bin) under /bin/sh (bash 3.2), bash 3.2 and zsh 5.9, repeated the alias and function paste in interactive bash 3.2 and zsh, and repeated the pseudo-terminal check under bash 3.2 as sh. All pass.

usage.logins is checked in a child process with HOME set to a temp dir, because Bun's os.homedir() doesn't follow a HOME set at runtime; the fixture refuses to run outside the temp dir.

Screenshots. I couldn't run the dev app in my environment, so these are Storybook renders of the changed components with sample data. The stories are not part of this PR.

Remote host, free name: the command
Remote host, taken name: no command
A name with no usable character
This machine, empty name: Sign in off
This machine, taken name
Card menu, a profile Paradev created
Card menu, any other profile

Checklist

  • PR title follows conventional commits (type(scope): subject)
  • bun run lint and bun run typecheck pass (CI fails on lint warnings too). Lint ran over the whole repo; typecheck ran for the two touched packages, see above.
  • "Allow edits from maintainers" is checked on fork PRs
  • I have signed the CLA (the bot asks on your first PR). Signed on #9059.

🤖 Generated with Claude Code


Summary by cubic

Settings → Usage's Add account and Remove now touch only profile dirs Paradev created, so sign-in can no longer revoke a login already stored in a matched home, and Remove no longer deletes homes the user or another tool made.

Add account behavior

  • The name field no longer defaults to work; Sign in stays disabled until a name is entered, and the dialog refuses a name whose home is already a discovered login.
  • The command now creates the home with plain mkdir and writes a .superset-created marker before login; it reuses a dir only when that marker is present and no login has landed (so Try again still works), otherwise it exits before the CLI starts and prints a message naming the dir.
  • A login already at the path when the dialog opened is never taken as the new sign-in.

Remove behavior

  • Remove deletes only a dir whose .superset-created marker names the agent, and refuses anything else with PRECONDITION_FAILED shown as a toast, before any Keychain or rm work.
  • Trade-off: profiles added by earlier builds have no marker, so Remove leaves them in place — the message names the path for manual deletion.

Summary by CodeRabbit

  • New Features

    • Adding an account creates a dedicated profile directory. If the chosen name already uses an existing directory, sign-in is blocked and you’re prompted to choose another name.
    • Profile-directory conflict messages are available in multiple languages.
  • Bug Fixes

    • Removing a profile now deletes only directories created by Paradev for the matching agent. Other directories are left untouched, and removal is refused.
  • Documentation

    • Clarified how profile directories are created and what happens to user-created directories when removing a profile, including that macOS Claude Code credentials remain in Keychain.