Repository navigation
feat(cli): name VOLTAGE_API_KEY and the organization source on a 403 - #66
Conversation
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
thebrandonlucas
left a comment
There was a problem hiding this comment.
Review at 7c7034c (Aurelius). 1 low finding. Nothing blocking.
The sources match ambient_api_key's precedence (src/auth.rs:543): a key is sent only without --profile or --account, and environment_key_sources uses the same check. A 401 and a 403 to a saved credential report no sources. Unit and integration tests cover the --org and VOLTAGE_ORGANIZATION_ID sources.
Low: src/api.rs:112 and src/api.rs:141. With VOLTAGE_API_KEY, the 403 hint replaces the old voltage context hint, and it names only the organization: "Check that the key belongs to that organization". A 403 can also come from the environment or the wallet (--env, --wallet, VOLTAGE_ENVIRONMENT_ID). In that case the new hint sends the user to the wrong place, and it no longer names the command that shows the whole scope. Fix: keep the key's source, and add "voltage context shows the organization and environment in use". Or say "the organization and environment" in place of "that organization". Update the pinned strings in a_forbidden_environment_key_names_its_sources, the integration test, and troubleshooting.md.
Note: the new environment_key_sources()? calls in pick.rs and wallet_currency.rs can fail only on a bad VOLTAGE_ORGANIZATION_ID. Scope resolution already rejects that earlier, so I don't ask for a test of that path.
Checks on 7c7034c:
- Gate in
nix develop: fmt, clippy, machete, tests (110 unit, 95 integration) and coverage pass, and the tree is clean. - CRAP: 0 functions at or above 20. The highest is
wait_for_paymentat 19.60, thencheck_payloadandendpointat 17.00. - Signed
G blu@blu.cx, with no trailers. - Not run: a staging check with a key from another organization.
thebrandonlucas
left a comment
There was a problem hiding this comment.
First review at 7c7034c, against e3e0c9a. This is not an approval. The main path is right:
environment_key_sourcesuses the sameambient_api_keytest asselect, so sources appear exactly whenVOLTAGE_API_KEYis the credential sent.- The organization source comes from the same
sourced_scopethat commands use. - Integration tests cover
--org,VOLTAGE_ORGANIZATION_ID, a 401 with no sources, and a saved credential that ignores the key.
I found 1 low problem and 2 nits.
1. Low: a 403 with a non-JSON body gets the new hint but no credential_sources.
src/api.rs:278: when the body is not JSON, the error getsrejection_hint(status)but noErrorDetail.- So a 403 from a proxy or gateway (HTML or empty-but-not-JSON) prints "The API key came from VOLTAGE_API_KEY…", while
--jsonhas noerror.detail.credential_sources. exit-codes.mdandtroubleshooting.mdsay that--jsonreports the sources on a 403. This path is not tested.
Fix: attach ErrorDetail::Http { http_status: status, data: Value::Null, credential_sources: self.authorization.credential_sources(status) } there too, and add an integration test with a text/html 403. Or limit the docs to JSON rejections.
2. Nit: the hint and the sources can disagree for missing_credentials (src/api.rs:292-304).
- A
missing_credentialsrejection usesrejection_hint(401), butcredential_sources(status)uses the real status. - So a 403 of that type gets the 401 hint ("the API does not accept it") together with
credential_sources. Your unit test comment says a rejected key should report no sources. - Fix: compute the effective status once and pass it to both calls.
3. Nit: the hint has a dangling "that organization" when the source is unknown. environment_key_hint (src/api.rs:140) drops the organization clause for Profile, Default or Unset. The next sentence then says "Check that the key belongs to that organization". Use "the selected organization" when there is no clause.
Checks on 7c7034c:
- Gate in
nix develop: fmt, clippy, machete, tests (110 unit, 95 integration) and coverage pass, and the tree is clean afterwards. - CRAP, with the rustup toolchain: 0 of 527 measured functions are at or above 20. The highest is
wait_for_paymentat 19.60.sendis at 13.04. - Not run: a staging 403 with a key from another organization, and a live non-JSON 403.
7c7034c to
b7a3e92
Compare
thebrandonlucas
left a comment
There was a problem hiding this comment.
Re-review at b7a3e92. This is not an approval. My 3 findings are fixed, and I found nothing new.
- Non-JSON 403: the error carries
credential_sourcesonly when sources exist, withdata: null. Not attaching a general HTTP detail there is right:http_status() == Some(404)atsrc/api.rs:845and:1292would otherwise treat an HTML 404 as a missing resource. The newtext/htmltest covers it. missing_credentials:rejected_asnow feeds both the hint and the sources. The new test pins the 401 hint and no sources on a 403 of that type.- "that organization": the new wording names the organization, environment, and wallet in use and points to
voltage context, which shows all three (src/context.rs:171).
Checks on b7a3e92:
- Gate in
nix develop: fmt, clippy, machete, tests (110 unit, 97 integration) and coverage pass, and the tree is clean afterwards. - CRAP, with the rustup toolchain: 0 of 527 measured functions are at or above 20. The highest is
wait_for_paymentat 19.60.sendis at 14.01. - The commit is signed
G blu@blu.cx. - Not run: a staging 403.
thebrandonlucas
left a comment
There was a problem hiding this comment.
Re-review at b7a3e92 (Aurelius). My low finding is fixed: the 403 hint now names the organization, environment and wallet, and points to voltage context. Cicero's three fixes are also correct:
- A non-JSON 403 attaches
credential_sourcesonly when there are sources.http_status()is compared only with 404 (src/api.rs:845,:1292), so the new 403 detail changes no retry or read path. rejected_asdrives both the hint and the sources.- The no-source hint is pinned.
No findings remain.
Checks on b7a3e92: fmt, clippy, machete, tests (110 unit, 97 integration) and coverage pass, and the tree is clean. CRAP: 0 functions at or above 20. The highest is wait_for_payment at 19.60. Signed G blu@blu.cx, with no trailers.
tee8z
left a comment
There was a problem hiding this comment.
Reviewed at b7a3e92. CRAP on this head: 0 of 527 functions at or above 20, highest wait_for_payment at 19.60. The reported sources match the credential that is actually sent in each precedence case, and no secret reaches the new field. No blocking issues.
resolver.environment_key_sources()?runs again inpick.rs,wallet_currency.rs, andAuthorization::new, re-reading the flags and environment each time. Its error path (a malformedVOLTAGE_ORGANIZATION_ID) can't be reached in practice and is untested. Threading theScopeSourcesfrom scope resolution would remove both problems.CredentialSources.credentialis alwaysSource::Environment, andorganizationcan serialize as"unset", whichtroubleshooting.mddoesn't list.- The PR description ends with a "Generated with Claude Code" line; AGENTS.md says to leave tool attribution out of PR descriptions.
The inline suggestion flattens the non-JSON error construction. 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.
tee8z
left a comment
There was a problem hiding this comment.
Approved b7a3e92 against fix/scripts-build-then-run. Credential provenance matches selection precedence. The 401, 403, non-JSON, missing_credentials, and saved-credential cases preserve their intended behavior. Remaining expression-shape suggestions are nonblocking. Lower-stack requested changes remain tracked on #62.
Validation on this unchanged head: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked, and python3 scripts/check-coverage.py passed. CRAP: 527 measured functions, none at or above 20; highest wait_for_payment 19.60. 143 test functions were unmeasured.
b7a3e92 to
f49cb19
Compare
|
@tee8z Thanks. All three notes are addressed in
Checks on |
tee8z
left a comment
There was a problem hiding this comment.
Approved f49cb19 against feat/cli-next-step-after-accept (#62 c1dc498). My three notes on b7a3e92 are fixed:
Session::openresolves the scope once withsourced_scopeand carriescredential_sourcesonResolver.api.rs,pick.rsandwallet_currency.rsread that field.Settings::scopeis nowsourced_scope(..).0, so the scope is unchanged, and the error path that could not be reached is gone.troubleshooting.mdlists eachorganizationvalue, and says thatcredentialis alwaysenvironment.- The non-JSON 403 is now a single
Error { hint, detail, .. }expression, and the description has no attribution line.
Nonblocking: when the wallet picker supplies the organization, credential_sources.organization is unset, and the hint names only the key. The behavior was the same on b7a3e92.
Validation on this head, in nix develop: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (110 unit, 97 integration) and python3 scripts/check-coverage.py passed. CRAP: 527 measured functions, none at or above 20, highest wait_for_payment at 19.60; 143 functions were unmeasured. When I approved, CI on aarch64 Linux was still pending, and the other jobs had passed. The changes requested on #62 still block this stack.
c1dc498 to
cc73897
Compare
f49cb19 to
caf6c5e
Compare
908b529 to
0b6e476
Compare
caf6c5e to
0a22d4c
Compare
tee8z
left a comment
There was a problem hiding this comment.
Re-approved 0a22d4c. It is a rebase onto #62 0b6e476, with the same patch as f49cb19. Only the CHANGELOG context changed. Validation on this tree, in nix develop: the full gate passed (111 unit, 97 integration). CRAP: 529 measured functions, none at or above 20, highest wait_for_payment at 19.60. CI is green.
`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
|
My approval was dismissed by the push that merged #67 as
After a rebase that changes only the base, with CI green, I will approve. |
a25b69d to
c64414a
Compare
* 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 5 of 6. Base:
feat/cli-next-step-after-accept(#62), now that #64 and #65 are merged into it. Next:feat/cli-login-replaces-same-user(#67).Closes #47.
Problem
VOLTAGE_API_KEYtakes precedence over a saved login. When that key does not belong to the selected organization, every request failed with a 403 whose hint said only to runvoltage context. Nothing said that an environment key was used instead of the saved login.Behavior
A 403 to
VOLTAGE_API_KEYnow names the variable and where the organization came from, and gives the next step:error.detail.credential_sourcesVOLTAGE_API_KEY,--org--org{"credential": "environment", "organization": "flag"}VOLTAGE_API_KEY,VOLTAGE_ORGANIZATION_ID{"credential": "environment", "organization": "environment"}VOLTAGE_API_KEYvoltage auth status, as beforevoltage context, as beforeVOLTAGE_API_KEY,missing_credentialsbodyvoltage auth status, as for a 401Authorization::Accountcarries the sources, decided once when the session resolves its scope (auth::environment_key_sources, from the sameScopeSources) and carried onResolver. The wallet picker and the wallet-currency read pass them too. Flags and the environment are not read again.missing_credentialsrejection is treated as a 401 for both the hint and the sources.--profileor--account, so the organization comes from--orgorVOLTAGE_ORGANIZATION_ID.troubleshooting.md(HTTP 403, including everyorganizationvalue:environment,flagandunset) andexit-codes.mdare updated.Checks
On head
f49cb19:cargo fmt --check,cargo clippy --locked --all-targets -- -D warnings,cargo machete --with-metadata,cargo test --locked(110 unit, 97 integration), andpython3 scripts/check-coverage.pypass innix develop.a_forbidden_environment_key_names_where_it_and_the_organization_came_from: both organization sources, the hint and the JSON sources.an_unauthorized_environment_key_reports_no_sources: a 401 keeps its hint and has no sources.a_forbidden_environment_key_without_a_json_body_still_names_its_sources: atext/html403 keeps the hint and the JSON sources.an_unrecognized_environment_key_on_a_403_reports_no_sources: amissing_credentials403 gets the 401 hint and no sources.rejected_credentials_name_the_command_that_checks_themnow uses a saved key, and checks that a saved credential reports no sources.a_forbidden_environment_key_names_its_sourcescovers each organization source, including none, andErrorDetail::Httpserialization.wait_for_paymentat 19.60, as on the base.voltage-pr-review.G blu@blu.cx, with no trailers.