fix(keyring): actionable error + documented bypass when the OS keyring is unavailable#416
fix(keyring): actionable error + documented bypass when the OS keyring is unavailable#416piekstra wants to merge 2 commits into
Conversation
Automated PR ReviewReviewed commit: Summary
go:implementation-tests (2 findings)Minor -
|
| Field | Value |
|---|---|
| Model | claude-sonnet-4-6 |
| Reviewers | documentation:docs, go:implementation-tests, policies:conventions |
| Engine | claude_cli · claude-sonnet-4-6 |
| Reviewed by | cr · piekstra-dev |
| Duration | 3m 43s wall · 3m 34s compute |
| Cost | ~$0.27 (est.) |
| Tokens | 18 in / 4.5k out |
Per-workstream usage
| Workstream | Model | In | Out | Cache read | Cache create | Cost | Duration |
|---|---|---|---|---|---|---|---|
| orchestrator-selection | claude-sonnet-4-6 | 3 | 124 | 3.3k | 4.8k | ~$0.02 (est.) | 21s |
| documentation:docs | claude-sonnet-4-6 | 4 | 1.2k | 7.2k | 12.3k | ~$0.07 (est.) | 24s |
| go:implementation-tests | claude-sonnet-4-6 | 3 | 124 | 3.3k | 4.8k | ~$0.02 (est.) | 1m 29s |
| policies:conventions | claude-sonnet-4-6 | 4 | 2.6k | 7.2k | 13.4k | ~$0.09 (est.) | 51s |
| orchestrator-rollup | claude-sonnet-4-6 | 4 | 532 | 19.6k | 14.3k | ~$0.07 (est.) | 28s |
piekstra-dev
left a comment
There was a problem hiding this comment.
Automated PR review completed with outcome: approved.
Automated PR ReviewReviewed commit: Summary
go:implementation-tests (1 finding)Minor -
|
| Field | Value |
|---|---|
| Model | claude-sonnet-4-6 |
| Reviewers | documentation:docs, go:implementation-tests, policies:conventions |
| Engine | claude_cli · claude-sonnet-4-6 |
| Reviewed by | cr · piekstra-dev |
| Duration | 3m 52s wall · 3m 50s compute |
| Cost | ~$0.46 (est.) |
| Tokens | 20 in / 11.7k out |
Per-workstream usage
| Workstream | Model | In | Out | Cache read | Cache create | Cost | Duration |
|---|---|---|---|---|---|---|---|
| orchestrator-selection | claude-sonnet-4-6 | 5 | 2.1k | 22.7k | 18.4k | ~$0.11 (est.) | 44s |
| documentation:docs | claude-sonnet-4-6 | 4 | 1.4k | 7.7k | 12.3k | ~$0.07 (est.) | 29s |
| go:implementation-tests | claude-sonnet-4-6 | 4 | 5.4k | 7.2k | 18.3k | ~$0.15 (est.) | 1m 31s |
| policies:conventions | claude-sonnet-4-6 | 4 | 2.8k | 7.2k | 17.9k | ~$0.11 (est.) | 55s |
| orchestrator-rollup | claude-sonnet-4-6 | 3 | 124 | 3.3k | 4.8k | ~$0.02 (est.) | 9s |
piekstra-dev
left a comment
There was a problem hiding this comment.
Automated PR review completed with outcome: approved.
…available
Runtime token resolution opens the OS keyring on every API command via
keyring.ResolveToken -> Open(). On a machine where the keyring is locked,
unreachable, or being driven headless under an agent (the OS unlock prompt
never surfaces), the command failed with an opaque backend error and the
unhelpful "run init" hint -- even for a user whose config file was already
populated. There was no signposted way to run keyring-free.
This wraps genuine keyring open/read failures in ResolveToken with an
actionable message that names the existing, standard-sanctioned escape
hatches, in resolution order:
- the per-tool / shared API-token env vars (JIRA_API_TOKEN /
CFL_API_TOKEN / ATLASSIAN_API_TOKEN), which are resolved BEFORE the
keyring is ever opened, so the keyring is never touched;
- the encrypted-file backend (--backend file) + its passphrase env var,
and the pass backend (--backend pass).
The original error is preserved via %w so errors.Is classification
(e.g. credstore.ErrSecretServiceFailClosed / ErrBackendNotImplemented)
still works. The §1.8 corrupt-shared-config graceful-degradation path is
untouched: when the keyring itself is healthy it still resolves with no
error. No new plaintext path is introduced -- the env var and file
backend are the Secret-Handling Standard's own §1.4 fallbacks, merely
surfaced so users discover them instead of hitting a silent prompt.
Also documents these keyring-free options in the README so the escape
hatch is discoverable before a user hits the failure.
Tests cover both paths: keyring-open failure -> wrapped, actionable,
underlying error preserved; env token set -> keyring fully bypassed;
plus a regression guard that a corrupt shared config with a working
keyring is NOT wrapped.
Closes #384
…ackend hint
Address review feedback on the keyring escape-hatch error:
- keyringUnavailableError applied the headless/no-keyring advice ("no
usable keyring", env vars, --backend file/pass) to EVERY non-nil error
from resolveFromStore. A decode failure, format mismatch, or store
sentinel (e.g. ErrStoreClosed) would be mislabeled with that advice.
Introduce an isBackendError predicate matching the backend-availability
sentinels (ErrSecretServiceFailClosed, ErrBackendNotImplemented,
ErrFilePassphraseRequired) and wrap only when it matches; all other
errors propagate unwrapped (errors.Is still classifies them). The
function's "ONLY real backend errors" contract is now enforced, not
just documented, and applies uniformly to every call site including
the ErrCorruptStore branch.
- The "active backend" hint hardcoded both tool names
(`cfl config show` / `jtk config show`) despite taking a tool param; a
third tool would get the wrong hint. Derive it from tool:
`%s config show`.
- Cover the previously-untested branch where Open succeeds but
resolveFromStore then fails: resolveFromStore is now a package var test
seam so a fake can return a backend (wrapped → actionable) or a
non-backend (unwrapped) read error, asserting the predicate end to end.
0ae8e85 to
036c91c
Compare
Closes #384
Summary
Root cause. Since the token was moved into the OS keyring (#366 / #369), runtime token resolution opens the keyring on every API command:
keyring.ResolveToken→Open()→cccredstore.Open(...)(and, when a plaintext token still sits in a config file, the one-time §1.8 migration, which is a keyring write). On the reporter's machine (KDE Plasma / Debian, Secret Service reachable) this means the keyring is touched on every invocation. When the keyring is locked, or the CLI is driven headless under an agent, the OS unlock prompt never surfaces — so the command just fails. The error that did surface was the opaque backend error plus the unhelpful "runinit" hint (which itself opens the keyring), and there was no signposted way to run keyring-free. That is the regression the reporter hit even though their config file was already populated.Re-introducing a config-file token resolver would violate the Secret-Handling Standard (§1.1/§1.2: runtime secrets never come from the plaintext config file). The standard already defines the correct keyring-free fallbacks (§1.4) — they were simply undiscoverable at the moment of failure.
Fix. Wrap genuine keyring open/read failures in
ResolveToken(the shared chokepoint for bothcflandjtk) with an actionable message naming the existing, standard-sanctioned escape hatches, in resolution order:JIRA_API_TOKEN/CFL_API_TOKEN/ATLASSIAN_API_TOKEN) — resolved before the keyring is ever opened, so the keyring is never touched (the canonical headless answer);--backend file) + its passphrase env var, and thepassbackend (--backend pass).Properties preserved:
%w, soerrors.Isclassification (credstore.ErrSecretServiceFailClosed,ErrBackendNotImplemented, …) still works.Also documents these keyring-free options in
README.mdso the escape hatch is discoverable before a user hits the failure.Files
shared/keyring/resolve.go—keyringUnavailableErrorhelper + wrapping inResolveToken.shared/keyring/resolve_test.go— new tests (both paths + regression guard).README.md— new "Headless and Keyring-Free Environments" subsection.Testing
All commands run with the repo's documented flags (
GOFLAGS=-tags=keyring_no1password,keyring_nopassage, per the Makefile).go build -o bin/cfl ./tools/cfl/cmd/cfland… bin/jtk …— pass (both binaries build).go test -race ./shared/...— pass.go test -race -count=3 ./shared/keyring/...— pass (new tests stable under the race detector).go test -race ./tools/jtk/...— pass.golangci-lint runinshared/,tools/cfl/,tools/jtk/— 0 issues each.go mod tidyacross all three modules — clean (no diff).New tests:
TestKeyringUnavailableError_WrapsAndNamesEscapeHatches— wraps underlying error (errors.Is), names env vars +--backend file/pass+ passphrase var, no-op on nil.TestResolveToken_KeyringOpenFailure_WrappedActionable— forces a real backend-open failure (unknown backend) → actionable wrapped error, underlyingErrBackendNotImplementedpreserved.TestResolveToken_EnvBypassesBrokenKeyring— withATLASSIAN_API_TOKENset, the keyring is never opened even when the backend is broken (the config-file-present user's fix).TestResolveToken_CorruptSharedConfig_NotWrappedWhenKeyringWorks— regression guard for the graceful-degradation contract.Caveat (unrelated, pre-existing)
go test -race ./tools/cfl/...intermittently fails inTestConfig_LoadFromEnv/env_vars_override_existing_values(tools/cfl/internal/config/config_test.go). Twot.Parallel()subtests mutate the same process env vars viaos.Setenv(nott.Setenv) and race each other. It reproduces onmainwithout this change and is independent ofshared/keyring(different module). Left untouched as out of scope; happy to file a separate issue.