Repository navigation
feat(cli): replace a saved login when the same user signs in again - #67
Conversation
`voltage login` under a saved name now replaces that login when the new session is for the same user at the same auth URL, under the credential lock, and revokes the replaced refresh token. A different user, or a saved login without a user ID, is still refused, and the new session is revoked. With `--account NAME`, a name saved for an API key, a login at another auth URL, or a login whose secret cannot be read is refused before the browser step. An unreadable secret is refused as a name in use with a `voltage logout --local --account NAME` hint, and `logout --local` removes an account whose secret is already gone, or warns when the secret cannot be removed. The expired-login hint is now only `voltage login --account NAME`. Without `--credential-store`, a replaced login keeps its store and a new login uses the keychain. Any failed secret or configuration write puts the replaced login back, including a write that already renamed its file, and the error says when the replaced login could not be restored. A Ctrl-C while the replaced session is revoked says the new login is saved. `shell_word` moves to `config`, which no longer imports `auth`. Closes #48
thebrandonlucas
left a comment
There was a problem hiding this comment.
Review at 2c776c6 (Aurelius). 2 low findings. Nothing blocking.
The main path is right:
put_accountreloads under the lock, writes the new secret, saves, and returns the old credential for revocation.- On a failed save,
restoreputs the old account and secret back.a_replacement_that_cannot_be_saved_restores_the_saved_loginreally exercises this: the new secret goes into the 0700credentials/directory, so onlysave()fails under the 0500 parent. - Another user and another auth URL are refused. The other-URL case is refused before any request.
1. Low: src/config.rs:605: put_account reads the saved secret before replaces decides, and a read failure returns early. This changes add_account, whose predicate always refuses. If a saved name's secret is missing, unreadable, or in a locked keychain, auth import-key --account NAME now fails with "Cannot read OS credential store…", an I/O error, or "Invalid saved credential". Before, it failed with name_in_use. For login, the same failure comes after the browser step: the new session is revoked, and the error is about the old secret, not the name. Fix: decide on the account first, and read the secret only when the account could be replaced. For example, pass is_login_at separately, or have add_account refuse when the name exists without reading. Map a failed read of the old secret to name_in_use, so the user is told to log out first. Add a test that removes a saved login's credential file and then runs add_account, or a same-user login.
2. Low (tests): new branches with no test:
src/auth.rs:724: a saved login with nouser_idis refused after the browser step.src/config.rs:621and:638(restore): a replacement into a different--credential-store. On success this deletes the old store's secret; on a failed save it deletes the new one.
Please add at least the user_id: None case. The store-change case needs a keychain, so a unit test that drives put_account from File into a stubbed store, or a comment saying why it is untested, would do.
Checks on 2c776c6:
- Gate in
nix develop: fmt, clippy, machete, tests (112 unit, 100 integration) and coverage pass, and the tree is clean. - CRAP: 0 functions at or above 20. The highest is
wait_for_paymentat 19.60.loginis 11.22. - Signed
G blu@blu.cx, with no trailers. - Not run: a login against staging, and a keychain-backed replacement.
There was a problem hiding this comment.
First review at 2c776c6, against b7a3e92. This is not an approval. The main flow is right:
- The
--accountprecheck refuses an API key or another auth URL before the device request, and a test checks that no request is sent. put_accounttakes the lock, reloads, and asksreplaceswith the saved account and credential. The user check needsold.user_id.is_some(), so a login without a user ID is never replaced.- A different user's new session is revoked by the existing
loginerror path. - I checked the auth service (
codex/cli-device-loginfd88993,src/device_oauth.rs:436): revoking a token it does not know returns 204. So replacing an expired or revoked login does not print the "Could not revoke" warning.
I found 1 medium problem, 1 low problem and 1 nit.
1. Medium: an unreadable saved secret now blocks login after the browser step, in a loop.
src/config.rs:605:put_accountreads the old credential with?before it asksreplaces.- When a keychain login's secret cannot be read (locked or missing keychain, or a removed entry),
voltage login --account NAMEruns the whole browser step and then fails with "Cannot read OS credential store; unlock it or log in with --credential-store file". It also revokes the new session. - Following that advice (
--credential-store file) fails the same way, because the old account still names the keychain.troubleshooting.md:95gives the same advice. An unparseable credential file fails the same way with "Invalid saved credential". - Before this PR,
--account NAMEwas refused withname_in_usebefore the browser step, and the expired-login hint namedvoltage logout --local. That hint is now gone. - No test covers it.
Fix: when the old credential cannot be read, refuse with name_in_use and a hint to run voltage logout --local --account NAME first. Do this before the browser step when --account is given, and in put_account otherwise. Add a test with an unreadable credential file, for example invalid JSON.
2. Low: the store-change cleanup is untested.
src/config.rs:621-623deletes the replaced secret when the new login uses another credential store.:637-639removes the new secret on restore.- Coverage shows both branches at 0 hits. Replacing a keychain login with
--credential-store file, or the reverse, is the only way to reach them. - The repository has no keychain mock, so this may need one, or a store-agnostic seam for
delete_secret. If you leave them untested, say so in the PR description.
3. Nit: CHANGELOG.md:25 adds a blank line inside the "Changed" list, which makes it a loose list. Remove it.
Checks on 2c776c6:
- Gate in
nix develop: fmt, clippy, machete, tests (112 unit, 100 integration) and coverage pass, and the tree is clean afterwards. - CRAP, with the rustup toolchain: 0 of 537 measured functions are at or above 20. The highest is
wait_for_paymentat 19.60.loginis at 11.22,put_accountat 9.00, andrestoreat 3.00. - The commit is signed
G blu@blu.cx. - Not run: a staging login, and a real keychain replacement.
2c776c6 to
be91ef5
Compare
thebrandonlucas
left a comment
There was a problem hiding this comment.
Re-review at be91ef5 (Aurelius). Both of my findings are fixed:
put_accountchecksreplaceablebefore it reads the secret, soadd_accountandimport-keyrefuse withname_in_useand read nothing.- An unreadable replaceable secret is refused with a
logout --localhint, before the browser step when--accountis given.logout --localnow tolerates a missing secret. user_id: None, a missing or corrupt file, and the in-lock path are tested. The store-change gap is stated in the code and in the description.
Nit: src/config.rs:9 now imports auth::shell_word, while auth imports config. Rust allows the cycle, but it puts a lower-level module under the higher one. Consider moving shell_word into config, or into a small shared module, and importing it from auth. This does not block.
Checks on be91ef5:
- Gate in
nix develop: fmt, clippy, machete, tests (113 unit, 102 integration) and coverage pass, and the tree is clean. - CRAP: 0 functions at or above 20. The highest is
wait_for_paymentat 19.60.loginis 13.21. - Signed
G blu@blu.cx, with no trailers. - Not run: a staging login, and a keychain replacement.
thebrandonlucas
left a comment
There was a problem hiding this comment.
Re-review at be91ef5. This is not an approval. My medium finding, my low finding and my nit are fixed:
put_accountchecksreplaceablebefore it reads the secret. A replaceable secret that cannot be read is refused asname_in_usewith a removal hint.- With
--account,loginchecks this before the device request, and the integration test checks that no request is sent. delete_secrettreats a missing file orNoEntryas already removed, so the hint'slogout --localworks for a missing or damaged file. The test checks that.- The store-change branches are untested, and the code comments and the PR description now say so.
- The blank line in the CHANGELOG is gone.
I found 1 low problem and 1 nit.
1. Low: a keychain that is unavailable, not just locked, still has no way out.
unreadable_name(src/config.rs:744) tells a keychain user to "Unlock the OS credential store, or remove it withvoltage logout --local --account NAME".- When the store cannot be reached, as on headless Linux without a Secret Service (the case
troubleshooting.mdgives for this error),delete_credentialreturns a platform error, notNoEntry. - So
logout --localfails with "Cannot remove saved credential" atsrc/auth.rs:971, before it removes the account, and the name stays in use.
Fix: either add "or sign in under another name with --account" to the keychain hint, or let logout --local remove the account with a warning when only the keychain delete fails. The hint is the smaller change.
2. Nit: config now imports auth::shell_word (src/config.rs:9). That makes config depend on auth, which already depends on config. Consider moving shell_word to a module both can use, such as output or a small shell module.
Checks on be91ef5:
- Gate in
nix develop: fmt, clippy, machete, tests (113 unit, 102 integration) and coverage pass, and the tree is clean afterwards. - CRAP, with the rustup toolchain: 0 of 539 measured functions are at or above 20. The highest is
wait_for_paymentat 19.60, andloginis at 13.21. - The commit is signed
G blu@blu.cx. - Not run: a staging login, and a real keychain, locked or unavailable.
tee8z
left a comment
There was a problem hiding this comment.
Reviewed at be91ef5. CRAP on this head: 0 of 539 functions at or above 20, highest wait_for_payment at 19.60 (login 13.21). There is one bug I'd fix before merge (inline on src/auth.rs, store: flags.credential_store), a couple of smaller issues, and some suggestions that flatten the nested matches in config.rs.
--jsonoutput gains areplacedfield. It deserves a line in the CHANGELOG and docs, since agents read that output.config.rsnow importsauth::shell_word, whileauthalready depends onconfig. Movingshell_wordto a shared module would avoid the cycle.- The PR description ends with a "Generated with Claude Code" line; AGENTS.md says to leave tool attribution out of PR descriptions.
I applied the suggestions on this head and checked them: cargo clippy --all-targets -- -D warnings is clean and the unit and integration tests pass. I applied all of them together.
tee8z
left a comment
There was a problem hiding this comment.
Requesting changes on be91ef5. Three correctness findings remain:
- Credential replacement can leave a revoked new token on disk and lose the previous local token. The new inline finding covers reproduced post-rename and rollback failures.
- Omitting
--credential-storeduring replacement selects keychain for a file-stored login. Preserve the saved store unless the caller explicitly chooses another. Reproduced with an unavailable Secret Service: exit 3 after authentication. - Ctrl-C while revoking the replaced session says the new login was not saved, although it is already persisted. Track saved-login state separately from old-session cleanup. Reproduced: exit 130 with new credentials on disk.
Validation: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked, and python3 scripts/check-coverage.py passed after one terminal-test timeout was rerun successfully. The instrumented suite also passed. CRAP: 539 measured functions, zero violations, highest wait_for_payment 19.04; 150 test functions unmeasured. A working OS keychain and live services were not exercised.
f49cb19 to
caf6c5e
Compare
be91ef5 to
c66d2a3
Compare
|
@tee8z All items from both reviews are addressed in
|
caf6c5e to
0a22d4c
Compare
c66d2a3 to
762363e
Compare
tee8z
left a comment
There was a problem hiding this comment.
Approved 762363e against feat/cli-403-credential-source (#66 0a22d4c). This head has the same tree as c66d2a3, which I verified. My three correctness findings on be91ef5 are fixed:
- Partial writes:
put_accountsends every secret-write or config-save failure throughrestore. That includes a write that already renamed its file.restorewrites the old secret back. If that fails, it checks whether the old secret is still stored, and then saves the config again. A lost login or config is reported with a hint:voltage login --account NAMEorvoltage logout --local --account NAME. Thewrite_faultstests cover failures before and after the rename for the secret, for the rollback, and for the config. Theput_accountlock is the only await, so Ctrl-C cannot stop it between the writes. - Store default:
--credential-storeis nowOption. Without it, a replaced login keepssaved.store, and a new login uses the keychain. The integration test replaces a file-stored login without the flag, and checks that it staysfile. - Ctrl-C: after
put_account, a replacement setsSessionChange::RevokeReplaced, so an interrupt during the old session's revoke says that the new login is saved. This is tested with SIGINT during a delayed revoke.
My nits are also fixed: replaced is in the CHANGELOG and login-and-profiles.md, shell_word is now in config with no config → auth import, the description has no attribution line, and the inline suggestions are applied. logout --local warns through discard_secret.
Nonblocking:
atomic_writeholds a#[cfg(test)]fault hook. It doesn't affect release builds, but a seam passed in by the caller would keep test code out of the write path.- After a config save that fails after its rename, followed by a failed retry, the disk keeps the new account's entry with the old secret. For the same user at the same auth URL, that entry differs only by store, which
saved_store_matcheschecks.
Validation on this tree, in nix develop: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (118 unit, 103 integration) and python3 scripts/check-coverage.py passed. CRAP: 555 measured functions, none at or above 20, highest wait_for_payment at 19.60; 152 functions were unmeasured. CI is green. A working OS keychain and live auth services were not exercised.
a25b69d
into
feat/cli-403-credential-source
…66) * feat(cli): name VOLTAGE_API_KEY and the organization source on a 403 VOLTAGE_API_KEY takes precedence over a saved login without saying so, so a key from another organization failed with a bare 403. A 403 to that key now names it and where the organization came from (--org or VOLTAGE_ORGANIZATION_ID), and suggests --account NAME or unsetting the variable. The JSON error reports both as detail.credential_sources. Closes #47 * feat(cli): replace a saved login when the same user signs in again (#67) `voltage login` under a saved name now replaces that login when the new session is for the same user at the same auth URL, under the credential lock, and revokes the replaced refresh token. A different user, or a saved login without a user ID, is still refused, and the new session is revoked. With `--account NAME`, a name saved for an API key, a login at another auth URL, or a login whose secret cannot be read is refused before the browser step. An unreadable secret is refused as a name in use with a `voltage logout --local --account NAME` hint, and `logout --local` removes an account whose secret is already gone, or warns when the secret cannot be removed. The expired-login hint is now only `voltage login --account NAME`. Without `--credential-store`, a replaced login keeps its store and a new login uses the keychain. Any failed secret or configuration write puts the replaced login back, including a write that already renamed its file, and the error says when the replaced login could not be restored. A Ctrl-C while the replaced session is revoked says the new login is saved. `shell_word` moves to `config`, which no longer imports `auth`. Closes #48 * fix(deps): reject the distrusted emSign intermediates (#71) * fix(deps): reject the distrusted emSign intermediates Update rustls-webpki to 0.103.16 and webpki-root-certs to 1.0.10. Both reject certificate paths through the emSign intermediates whose private keys are now public (RUSTSEC-2026-0382, RUSTSEC-2026-0383). Closes #68 Closes #69 * feat(auth): report login_expired as the credential problem (#72) When the auth service answers a saved login's refresh or organization token exchange with invalid_grant, the error's --json detail now carries "credential_problem": "login_expired", so agents can detect a dead login without parsing the message. voltage context stays unchanged. It reads no saved secret and makes no network request, so it cannot see that the auth service rejects a login, and an expired access token alone is refreshed by the next command. The troubleshooting and login guides say so. Closes #49
Stack 6 of 6. Base:
feat/cli-403-credential-source(#66).Closes #48.
Problem
voltage loginrefused any saved name, and only after the browser step: the user opened the URL, confirmed the code, and then gotCredential NAME already exists; log it out first. Signing in again as the same user, for example after the login expired, neededvoltage logout --local --account NAMEfirst. The expired-login hint said so.Behavior
--jsonreports"replaced": true.voltage logout --local --account NAME. With--account NAME, this happens before the browser step.--account NAME"replaced": false).--credential-storeis now optional. Without it, a replaced login keeps its saved store, and a new login uses the keychain, as before.Settings::put_accountsaves an account under the credential lock. It replaces a saved one only whenreplaceableaccepts the saved account and thensameaccepts its credential. The secret is read only for a replaceable account, soadd_account(and soauth import-key) refuses a used name withname_in_usewithout reading it.restore. It writes the old secret back, or removes the new one when nothing was replaced, and saves the config again. A rollback that fails is reported in the error's hint:voltage login --account NAMEwhen the old secret is lost, andvoltage logout --local --account NAMEwhen the config cannot be put back. A test-only hook inatomic_writeinjects failures before or after the rename.logout --local: removes an account whose secret is already gone. When the secret cannot be removed, it removes the account and prints a warning. A dropped secret delete during a store change also warns now, where it used to fail silently.voltage login --account NAME.shell_wordmoves fromauthtoconfig, soconfigno longer importsauth.login-and-profiles.md,troubleshooting.mdandexit-codes.mdare updated, including thereplacedfield in--jsonoutput.delete_secret, because the tests cannot use an OS keychain. Code comments say so.Checks
On head
c66d2a3:nix develop:cargo fmt --check,cargo clippy --locked --all-targets -- -D warnings,cargo machete --with-metadata,cargo test --locked(118 unit, 103 integration), andpython3 scripts/check-coverage.py.wait_for_paymentat 19.60.a_login_of_the_same_user_replaces_the_saved_one_and_revokes_it: replaces a file-stored login without--credential-store, and checks that the store staysfile. The tokens are replaced,replacedis true, and the old refresh token is revoked. When revocation fails, the login still succeeds and names the signout step.interrupt_while_revoking_a_replaced_login_says_the_new_one_is_saved: SIGINT during the delayed revoke. Checks the message and the saved new tokens.a_replacement_that_fails_after_a_rename_restores_the_saved_login(unit): a secret write failing after its rename, a write and its rollback both failing before the rename, and a config save failing after its rename. Each case reloads from disk and checks that the old secret and account are still saved.a_failed_rollback_names_the_lost_loginanda_new_account_that_fails_after_a_rename_leaves_nothing_saved(unit): the rollback hints, and that a new account leaves no account and no secret behind.a_secret_that_cannot_be_removed_is_discarded_with_a_warning(unit): thelogout --localpath.voltage-pr-reviewand the Rust style checklist. An independent review of the rollback found one bug, a config left on disk after a failed save, which is fixed in this head.G blu@blu.cx, with no trailers.