Repository navigation
feat(auth): discover Claude OAuth credentials via fallback chain when CLAUDE_CODE_OAUTH_TOKEN is missing or stale #102
Description
Background
Follow-up to #99 (closed). That incident exposed a systemic gap: askcc inherits whatever CLAUDE_CODE_OAUTH_TOKEN is in the parent shell/systemd environment, performs no validation, and on a stale or missing token surfaces an opaque HTTP 401 from the spawned claude -p subprocess. Three days of automated processing were blocked while the actual cause (env-file token had drifted from the canonical token file) was diagnosed by hand.
The downstream consumer (ellen-core) has shipped a workaround that overlays CLAUDE_CODE_OAUTH_TOKEN from a canonical token file at every claude fan-out point (ellen-core PR#125). That works, but it pushes auth-resolution logic into every caller of askcc instead of solving it once in askcc itself. The current askcc/runners.py::ClaudeRunner.run() does only env = os.environ.copy() — no awareness that an absent or invalid OAuth token is the most common failure mode in headless deployments.
Proposal
When askcc invokes a Claude runner, resolve CLAUDE_CODE_OAUTH_TOKEN via an explicit discovery chain rather than relying on whatever the parent process happened to inherit. Stop on the first source that produces a non-empty token; log which source won at INFO level so failures are diagnosable from the run log alone.
Discovery chain (in order)
CLAUDE_CODE_OAUTH_TOKENenv var — current behavior; preserves existing setups.CLAUDE_OAUTH_TOKEN_FILEenv var — when set, read the file at that path. Lets operators point askcc at a custom location (also useful for tests).- Conventional headless token file —
~/.tokens/.claude-oauth-token. De-facto canonical location used by~/.bashrcto populate the env var for interactive shells; reusing it here means cron/systemd sees the same token a developer sees on the command line. - XDG fallback —
${XDG_CONFIG_HOME:-~/.config}/claude/oauth-token. Covers operators who prefer XDG-compliant layouts. ~/.claude/.credentials.json— last resort. ParseclaudeAiOauth.accessToken. Emit a WARNING when this source is used, because the interactive Claude Code app refreshes its access token in RAM and does not always write back, so this file can be months stale even while the live IDE works fine. (See ellen-core'sINTEGRATED_AUTH_DETAILS.mdfor the in-memory ↔ on-disk asymmetry.)
If none of the sources produce a non-empty token, fail fast with a clear error message naming each location checked, before invoking claude — so the user sees no Claude credentials found in any of: env var, $CLAUDE_OAUTH_TOKEN_FILE, ~/.tokens/.claude-oauth-token, ... instead of a 401 from a subprocess.
Edge-case handling (resolved per @monkut's review)
- Unreadable file (
PermissionError) — log a WARNING naming the path and continue to the next source. - Malformed
~/.claude/.credentials.json— log a WARNING with the parse error and continue. - Trailing whitespace in token files —
.strip()file contents before treating as non-empty.
Optional: pre-flight validation (deferred)
A claude -p "ok" probe before the real call would catch a stale-but-present token early. Out of scope for the first cut; revisit if the discovery chain alone leaves the stale-token case unhandled.
Acceptance Criteria
- WHERE
CLAUDE_CODE_OAUTH_TOKENis unset or empty, WHEN askcc runs any action, THEN it shall consult sources 2–5 in order and use the first non-empty token found. - WHERE a token is loaded from a non-env source, WHEN the action runs, THEN an INFO log line shall name the source (e.g.
auth: loaded CLAUDE_CODE_OAUTH_TOKEN from ~/.tokens/.claude-oauth-token). - WHERE the source is
~/.claude/.credentials.json, WHEN the action runs, THEN a WARNING log line shall note that this source can be stale. - WHERE no source produces a non-empty token, WHEN askcc runs any action, THEN it shall exit non-zero with an error listing every path/var checked, without invoking
claude. - WHERE
CLAUDE_CODE_OAUTH_TOKENis set in the env, WHEN askcc runs, THEN behavior is unchanged from today (no regression for working setups). - WHERE a candidate token file is unreadable (
PermissionError) or~/.claude/.credentials.jsonis malformed JSON, WHEN the chain runs, THEN a WARNING is logged naming the path and the chain continues to the next source. - WHERE a token file's contents include trailing whitespace/newline, WHEN read, THEN the value is
.strip()-ed before being treated as non-empty or used. - Unit tests cover: env-var present (no fallback used), env-var missing + each fallback source present in isolation, malformed
credentials.json, unreadable token file, trailing-newline stripping, and the all-sources-empty error path. -
uv run pytest,uv run poe check, anduv run poe typecheckall pass.
Dependencies
None identified. The change is self-contained within askcc/runners.py and askcc/cli.py; uses only stdlib (os, pathlib, json, logging).
Implementation Plan
Current state
askcc/runners.py:88—env = os.environ.copy()is the only env handling inClaudeRunner.run(). No auth awareness.askcc/runners.py:89—env.pop("CLAUDECODE", None)is the only existing pre-subprocess env mutation.askcc/runners.py:101—subprocess.run(...)is the call to theclaudebinary.askcc/cli.py:288–306—runner.run(...)runs inside atry/finally; only handles tempfile cleanup, no exception types caught today.tests/test_askcc.py:563—TestClaudeRunneris the existing runner test class (usespatch("askcc.runners.subprocess.run", ...)); follow this pattern.tests/test_askcc.py:2037—TestClaudeRunnerThinkingOptionsshows the env-mutation test pattern (mock_run.call_args[1]["env"]); reuse for asserting the OAuth token was injected.- Negative grep — no existing references to
CLAUDE_CODE_OAUTH_TOKEN,_resolve_oauth_token,CLAUDE_OAUTH_TOKEN_FILE,claude-oauth-token, orcredentials.jsonanywhere in the repo. This is a green-field add.
Tasks
- Add
OAuthTokenNotFoundError(RuntimeError)and module-level constants inaskcc/runners.py(aboveClaudeRunner):OAUTH_TOKEN_ENV = "CLAUDE_CODE_OAUTH_TOKEN"OAUTH_TOKEN_FILE_ENV = "CLAUDE_OAUTH_TOKEN_FILE"CONVENTIONAL_TOKEN_FILE: Path→Path.home() / ".tokens" / ".claude-oauth-token"CREDENTIALS_JSON_FILE: Path→Path.home() / ".claude" / ".credentials.json"- The XDG path is computed inside
_resolve_oauth_token()soXDG_CONFIG_HOMEis read at call time, not import time.
- Add
_resolve_oauth_token() -> tuple[str, str]helper inaskcc/runners.py.- Returns
(token, source_label)wheresource_labelis human-readable ("env CLAUDE_CODE_OAUTH_TOKEN","file ~/.tokens/.claude-oauth-token", etc.). - Walks the chain in order. For each file source:
FileNotFoundError→ silent (source absent);PermissionErrorandjson.JSONDecodeError(or missing keys) →logger.warningand continue. - For each non-empty file content,
.strip()before checking truthiness. - For
credentials.json, parse JSON and readdata["claudeAiOauth"]["accessToken"]; missing keys treated as parse failure (warn + continue). Add a one-line comment naming the observed schema so future schema drift is detectable. - Raises
OAuthTokenNotFoundErrorlisting every path/env var checked when no source yields a token.
- Returns
- Wire
_resolve_oauth_token()intoClaudeRunner.run()ataskcc/runners.py:88–89.- Immediately after
env.pop("CLAUDECODE", None):token, source = _resolve_oauth_token()env[OAUTH_TOKEN_ENV] = token- When
sourceis the env var itself → log nothing (no-op for working installs). - When
sourceis any other →logger.info("[%s] auth: loaded %s from %s", issue_url, OAUTH_TOKEN_ENV, source). - When
sourceis the credentials.json fallback → alsologger.warning(...)about staleness.
- Immediately after
- Catch
OAuthTokenNotFoundErrorinaskcc/cli.py:main().- Wrap
runner.run(...)(currentlycli.py:288–298) so the existingfinally:tempfile cleanup still runs:except OAuthTokenNotFoundError as e: logger.error(str(e)); sys.exit(1)
- Import the exception from
.runners.
- Wrap
- Add a
TestResolveOAuthTokenclass intests/test_askcc.pycovering:test_env_var_present_no_fallback_used— env set, no file IO, source label is the env var.test_token_file_env_var_used_when_main_env_missing—CLAUDE_OAUTH_TOKEN_FILE→ tmp file with token.test_conventional_path_used_when_envs_missing— monkeypatchCONVENTIONAL_TOKEN_FILEto atmp_path.test_xdg_path_used— setXDG_CONFIG_HOMEto atmp_pathcontainingclaude/oauth-token.test_credentials_json_used_with_warning— patchCREDENTIALS_JSON_FILEto a tmp file{"claudeAiOauth":{"accessToken":"abc"}}; assert WARNING incaplog.test_trailing_newline_stripped— token file containing"abc\n"→ returned token is"abc".test_unreadable_file_warns_and_continues— patchPath.read_textto raisePermissionError; chain continues; WARNING logged.test_malformed_credentials_json_warns_and_continues— file contains"{not json"; raisesOAuthTokenNotFoundError; WARNING logged.test_empty_token_file_falls_through— file contains only whitespace; treated as absent.test_all_sources_empty_raises_with_paths— assert error message names every checked location.
- Extend
TestClaudeRunner(or add an integration test class) intests/test_askcc.py:test_runner_injects_resolved_token_into_subprocess_env— patch_resolve_oauth_tokento return("xyz", "..."); assertmock_run.call_args[1]["env"][OAUTH_TOKEN_ENV] == "xyz".test_runner_logs_source_for_non_env_resolution— assert INFO log line includes the source label.test_runner_logs_warning_when_credentials_json_used— assert WARNING about staleness.
- Add CLI exit-path test in
tests/test_askcc.py:test_main_exits_nonzero_when_oauth_resolution_fails— patch resolver to raiseOAuthTokenNotFoundError; assertSystemExit(1)and the error message hitscaplog.
- Update
README.md:- Add a row to the env-vars table (~line 96) for
CLAUDE_OAUTH_TOKEN_FILE. - Add a short "Authentication" subsection documenting the discovery chain order and the
credentials.jsonstaleness caveat.
- Add a row to the env-vars table (~line 96) for
- Verification gate (mandatory before opening the PR):
uv run pytest -v— all greenuv run poe check— ruff cleanuv run poe typecheck— pyright clean
Risks / open questions
~/.claude/.credentials.jsonschema is undocumented. If Claude Code changes the key path the fallback silently stops working. Mitigation: in-line comment naming the observed schema ({"claudeAiOauth": {"accessToken": "..."}}); the WARNING already covers staleness.- Pre-flight validation deliberately deferred. First cut closes the known-incident root cause (silent env drift); a stale-but-present token will still 401 from the subprocess. If that recurs, follow-up issue should add a
claude -p "ok"probe. - No
ANTHROPIC_API_KEYhandling. Per the issue's "Out of scope" — separate auth path with different semantics. - No token rotation/refresh. askcc consumes tokens, never mints them.
Out of scope
- Token refresh / rotation logic (askcc should consume tokens, not mint them).
- Any handling of
ANTHROPIC_API_KEY— separate auth path with different semantics; can be a follow-up if there's demand. - Changing how Claude Code itself resolves credentials — this is purely about askcc's resolution before it spawns the subprocess.
Rationale
- Single point of resolution. Today, every askcc consumer that runs in a headless context (cron, systemd, hooks) needs its own overlay logic. Solving it inside askcc removes that burden from every downstream caller.
- Better failure messages. A 401 from
claude -pis several layers removed from the actual problem (env-file drift, expired token, missing config). A discovery chain with explicit logging means the runner log itself names the source on every successful run and the missing locations on failure. - Self-healing across token rotation. When the operator writes a fresh token to
~/.tokens/.claude-oauth-token(the canonical headless location), askcc picks it up automatically without an env-file resync or daemon-reload. - No-op for existing working installs. Source Externalize agent prompt templates to ~/.askcc/templates/ #1 is the current behavior. Operators who already export
CLAUDE_CODE_OAUTH_TOKENsee no change.
References
- askcc CLI returns 401 auth error on all actions (2026-04-29) #99 — the original 2026-04-29/30 incident
- ellen-core PR#125 — the downstream workaround that this issue would let us retire
- ellen-core
INTEGRATED_AUTH_DETAILS.md— the three-locations-of-truth runbook that motivated the discovery chain
Activity
monkut commentedon Apr 30, 2026
Issue Review
Overall: Clear, complete, and feasible. The proposal accurately identifies the gap (
runners.py:87doesenv = os.environ.copy()with zero auth awareness) and provides everything needed to implement it.Clarity
Excellent. Background, ordered discovery chain, acceptance criteria, rationale, and out-of-scope are all present and unambiguous. The five-source chain is easy to implement against as written.
Completeness
The AC covers the happy path, all fallback branches, the credentials.json warning case, and the fast-fail error. Unit test matrix (env-var present, file fallback, credentials.json only, all-empty) is well-scoped.
Three minor gaps worth calling out:
Unreadable token files — the current spec says "read the file at that path" but doesn't specify whether a
PermissionErrorshould be treated as "source unavailable, continue chain" or a hard failure. Recommend: log a WARNING and continue to the next source, consistent with the fail-fast-only-when-all-empty behavior.credentials.json parse errors — if the file exists but is malformed JSON (a common partial-write scenario), should it also continue the chain? Recommend: yes, with a WARNING naming the parse error.
No mention of file-path stripping — token files written by shell scripts often include a trailing newline. The implementation should
.strip()the file contents before treating it as non-empty.Feasibility
Straightforward. The natural implementation point is a
_resolve_oauth_token() -> str | Nonehelper called just afterenv = os.environ.copy()inClaudeRunner.run(). No dependency changes needed;json,os,pathlib, andloggingare already available.One implementation note:
~/.claude/.credentials.jsonis an undocumented Claude Code internal format. If Claude Code changes the key name or nesting, the fallback silently stops working. The WARNING already accounts for staleness, but a comment in the code noting the schema version observed would help future maintainers detect breakage.Summary
Ready to implement as written once the three edge cases above are decided (file unreadable, malformed JSON, trailing newline). The pre-flight validation deferral is the right call — the discovery chain solves the known incident root cause without scope creep.
:tokens-used: model: N/A, input: 34, output: 1793
monkut commentedon Apr 30, 2026
Plan Summary
Rewrote the issue body to add
## Acceptance Criteria,## Dependencies, and## Implementation Planand assigned myself.Sections added/updated
Current state(citingaskcc/runners.py:88,:89,:101,askcc/cli.py:288–306, plus the negative grep for the proposed symbols), 9 ordered tasks with file/function refs, and aRisks / open questionsblock.Implementation shape
_resolve_oauth_token() -> tuple[str, str]andOAuthTokenNotFoundErrorinaskcc/runners.py; called immediately afterenv.pop("CLAUDECODE", None)atrunners.py:89(the spot @monkut recommended).cli.py:main()catchesOAuthTokenNotFoundError, logs the message, andsys.exit(1)— keeps the existing tempfile-cleanupfinally:intact.TestClaudeRunner(tests/test_askcc.py:563) andTestClaudeRunnerThinkingOptions(:2037); 10 unit tests for the resolver + 3 runner integration tests + 1 CLI exit-path test.Risks / open questions
~/.claude/.credentials.jsonschema is undocumented and could drift silently — mitigated with an in-line schema comment plus the existing WARNING.claude -p "ok"probe deferred per the issue; revisit only if stale-but-present tokens recur.:tokens-used: model: N/A, input: 23, output: 19911
monkut commentedon Apr 30, 2026
Implementation Summary
Opened PR #103 implementing the OAuth token discovery chain per the planned implementation.
Implementation
OAuthTokenNotFoundError(RuntimeError)and module-level constants (OAUTH_TOKEN_ENV,OAUTH_TOKEN_FILE_ENV,CONVENTIONAL_TOKEN_FILE,CREDENTIALS_JSON_FILE) inaskcc/runners.py._resolve_oauth_token() -> tuple[str, str]returning(token, source_label). Helpers_read_token_file()and_read_credentials_json()handleFileNotFoundError(silent),PermissionError(WARNING + continue), and JSON parse / schema errors (WARNING + continue). XDG path is computed at call time, not import time.ClaudeRunner.run()immediately afterenv.pop("CLAUDECODE", None). INFO log when source is non-env; WARNING when source is~/.claude/.credentials.json.askcc/cli.py:main()catchesOAuthTokenNotFoundError, logs it, andsys.exit(1). The existing tempfile cleanupfinally:is preserved.Tests added
TestResolveOAuthToken(14 tests) — env-var present (no fallback used), each fallback in isolation, env-var empty falls through, XDG default whenXDG_CONFIG_HOMEis unset, credentials.json with warning, trailing-newline stripping, unreadable file warns + continues, malformed JSON warns, missing schema keys warn, empty token file falls through, all-sources-empty error names every checked location, helper edge cases.TestClaudeRunnerOAuthIntegration(4 tests) — token injection into subprocess env, INFO log on non-env source, no log on env source, WARNING on credentials.json staleness.TestMainOAuthExitPath(1 test) — CLI exits 1 with error message incaplogwhen resolution fails.TestClaudeRunner,TestClaudeRunnerThinkingOptions, andTestRunnerFrontmatterFlagsgot an autouse fixture that setsCLAUDE_CODE_OAUTH_TOKENso they remain isolated from the host filesystem.Documentation
CLAUDE_CODE_OAUTH_TOKENandCLAUDE_OAUTH_TOKEN_FILE.Verification gate
uv run pytest— 256 passed.uv run poe check— ruff clean.uv run poe typecheck— pyright clean.Acceptance criteria coverage
All 9 ACs satisfied: env-var fallback chain, INFO log on non-env source, WARNING on credentials.json, fast-fail with named locations, no regression for working installs, WARNING-and-continue on PermissionError / malformed JSON,
.strip()on file contents, unit-test matrix, and verification gate.:tokens-used: model: N/A, input: 94, output: 44379
Merge pull request #103 from monkut/feature/102-claude-oauth-token-di…