Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,40 @@ jtk issues list --project PROJ
cfl page list --space DEV
```

### Headless and Keyring-Free Environments

The API token lives in the OS keyring (macOS Keychain, Windows Credential
Manager, Linux Secret Service). On a headless host, in CI, or under an
automation agent there may be no keyring — or one that is locked and
cannot be unlocked interactively (the unlock prompt never reaches you).
Both tools support keyring-free operation without putting the token in a
plaintext config file:

- **Supply the token via an environment variable.** `ATLASSIAN_API_TOKEN`
(or the tool-specific `JIRA_API_TOKEN` / `CFL_API_TOKEN`) is resolved
*before* the keyring is ever opened, so the keyring is never touched. This
is the canonical answer for `op run`-style setups and agent automation.

```bash
export ATLASSIAN_API_TOKEN="your-api-token"
jtk issues list --project PROJ # never touches the OS keyring
```

- **Use the encrypted-file backend.** `--backend file` (or
`ATLASSIAN_CLI_KEYRING_BACKEND=file`, or `keyring.backend: file` in the
config file) stores the token in an encrypted file instead of the OS
keyring. Supply its passphrase non-interactively with
`ATLASSIAN_CLI_KEYRING_PASSPHRASE` for unattended runs.

- **Use the `pass` backend.** `--backend pass` (or
`ATLASSIAN_CLI_KEYRING_BACKEND=pass`) integrates with an existing
[`pass`](https://www.passwordstore.org/) password-store — the canonical
headless-Linux option when you already run one.

If a command fails because the keyring is unavailable or locked, the error
names these same options. Run `jtk config show` / `cfl config show` to see
which backend is active on the current machine.

### Output Representations

Both tools support three output representations:
Expand Down
97 changes: 90 additions & 7 deletions shared/keyring/resolve.go
Comment thread
piekstra marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strings"
"sync"

Comment thread
piekstra marked this conversation as resolved.
cccredstore "github.com/open-cli-collective/cli-common/credstore"

"github.com/open-cli-collective/atlassian-go/credstore"
)

Expand Down Expand Up @@ -86,7 +88,12 @@ func envToken(tool string) (string, bool) {
// the one-time §1.8 migration (Open) and the effective key is read. Env
// winning does not force a keyring open — the migration then runs on the
// next invocation that does open it (opportunistic, template-consistent).
// Keyring errors propagate (never folded into "absent").
// Keyring errors propagate (never folded into "absent"), but they are
// wrapped with the headless/no-keyring escape hatches (see
// keyringUnavailableError) so a user on a machine without a usable OS
// keyring — or running the CLI headless under an agent, where the unlock
// prompt never surfaces (issue #384) — gets an actionable, self-service
// message instead of an opaque backend error.
func ResolveToken(tool string) (string, TokenSource, error) {
if v, ok := envToken(tool); ok {
return v, SourceEnv, nil
Expand All @@ -96,20 +103,91 @@ func ResolveToken(tool string) (string, TokenSource, error) {
// A corrupt shared CONFIG file only blocks the migration source;
// it must not kill the command. Defer migration, warn once, and
// still resolve the token from the keyring (non-migrating).
// Genuine keyring-backend errors still propagate.
// Genuine keyring-backend errors still propagate (wrapped with
// the escape hatches); other failures propagate unwrapped.
if errors.Is(err, credstore.ErrCorruptStore) {
warnCorruptOnce(err)
ns, nerr := OpenNoMigrate()
if nerr != nil {
return "", SourceNone, nerr
return "", SourceNone, keyringUnavailableError(tool, nerr)
}
defer func() { _ = ns.Close() }()
return resolveFromStore(ns)
tok, src, rerr := resolveFromStore(ns)
if rerr != nil {
return "", SourceNone, keyringUnavailableError(tool, rerr)
}
return tok, src, nil
}
return "", SourceNone, err
return "", SourceNone, keyringUnavailableError(tool, err)
}
defer func() { _ = s.Close() }()
return resolveFromStore(s)
tok, src, rerr := resolveFromStore(s)
if rerr != nil {
return "", SourceNone, keyringUnavailableError(tool, rerr)
}
return tok, src, nil
}

// isBackendError reports whether err is a genuine keyring-BACKEND
// availability failure — the only class for which the headless /
// no-keyring escape-hatch advice (env vars, --backend file/pass) is
// actually accurate. It matches the cli-common credstore sentinels that
// mean "the backend itself could not be opened or used":
// - ErrSecretServiceFailClosed: Secret Service present but locked/denied
// (the issue #384 headless case);
// - ErrBackendNotImplemented: the selected/default backend is unavailable
// on this platform/build;
// - ErrFilePassphraseRequired: the file backend cannot open without its
// passphrase (the advice names that env var).
//
// A credential-decode failure, a format mismatch, ErrStoreClosed, or any
// other non-backend error returns false so it propagates UNWRAPPED rather
// than being mislabeled with "no usable keyring" remedies.
func isBackendError(err error) bool {
return errors.Is(err, cccredstore.ErrSecretServiceFailClosed) ||
errors.Is(err, cccredstore.ErrBackendNotImplemented) ||
errors.Is(err, cccredstore.ErrFilePassphraseRequired)
}

// keyringUnavailableError wraps a genuine keyring backend-availability
// failure with the documented escape hatches so the headless / no-keyring
// case (issue #384) is self-service instead of an opaque backend error.
// It wraps ONLY real backend errors (see isBackendError): the §1.8
// corrupt-shared-config graceful path never reaches it, a benign "no token
// present" result is a nil error, and a non-backend failure (decode,
// format mismatch, store sentinel) is returned UNWRAPPED so it is not
// mislabeled with "no usable keyring" advice that does not apply.
//
// The wrapped error keeps the original via %w, so callers and tests that
// classify with errors.Is (e.g. credstore.ErrSecretServiceFailClosed)
// still match. The message names, in resolution order, the runtime escape
// hatches the Secret-Handling Standard already supports:
// - the per-tool / shared API-token env vars (resolved before the
// keyring is ever opened — the canonical headless answer);
// - the encrypted-file backend (--backend file) and its passphrase env
// var, plus the pass backend (--backend pass) for password-store users.
//
// No new plaintext path is introduced: the env var and file backend are
// the standard's own §1.4 fallbacks, surfaced here so users discover them
// instead of hitting a silent keyring-unlock prompt that never appears.
func keyringUnavailableError(tool string, err error) error {
if err == nil {
return nil
}
// Only backend-availability failures get the headless/no-keyring
// remedies; anything else propagates unwrapped (the advice would be
// misleading for, e.g., a decode failure or ErrStoreClosed).
if !isBackendError(err) {
return err
}
envVars := strings.Join(envVarsFor(tool), " or ")
return fmt.Errorf(
"could not read the API token from the OS keyring (%w). "+
"If you are running headless or have no usable keyring, supply the token via %s, "+
"or select the encrypted-file backend with --backend file "+
Comment thread
piekstra marked this conversation as resolved.
"(passphrase via %s) or the pass backend with --backend pass. "+
"See `%s config show` for the active backend",
err, envVars, passphraseEnvVar(Service), tool)
}

// ResolveTokenNoMigrate is the DIAGNOSTIC resolver (`config show` source
Expand All @@ -129,7 +207,12 @@ func ResolveTokenNoMigrate(tool string) (string, TokenSource, error) {

// resolveFromStore reads the single shared api_token. One key per logical
// credential (§1.11.10): jtk and cfl resolve the same key.
func resolveFromStore(s *Store) (string, TokenSource, error) {
//
// It is a package var (not a plain func) purely as a test seam: a test
// must be able to exercise the "Open succeeded but the read failed" branch
// of ResolveToken without a backend that can fail mid-read on every OS.
// Production code never reassigns it.
var resolveFromStore = func(s *Store) (string, TokenSource, error) {
if v, ok, err := s.get(KeyAPIToken); err != nil {
return "", SourceNone, err
} else if ok {
Expand Down
Loading
Loading