Skip to content
Merged
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,31 @@ Supporting commands can be used at any point:
| `ASKCC_LANGUAGE` | Default output language for agent comments (`english`, `japanese`) | `english` |
| `DECISION_ISSUE_LABEL` | GitHub label applied when an agent flags a decision is needed | `needs:decision` |
| `ENABLE_ISSUE_LABEL_PREFIX_VALIDATION` | Enable/disable issue label prefix validation before agent execution | `true` |
| `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code OAuth token used by the spawned `claude` subprocess | (none) |
| `CLAUDE_OAUTH_TOKEN_FILE` | Path to a file containing the OAuth token. Read when `CLAUDE_CODE_OAUTH_TOKEN` is unset/empty | (none) |

### Authentication

Before invoking the `claude` subprocess, askcc resolves the OAuth token via the
following discovery chain. The first source that produces a non-empty token wins;
the chosen source is logged at `INFO` level so failures are diagnosable from the
run log alone.

1. `CLAUDE_CODE_OAUTH_TOKEN` environment variable (no log when used).
2. `CLAUDE_OAUTH_TOKEN_FILE` environment variable — read the file at that path.
3. `~/.tokens/.claude-oauth-token` — conventional headless token file used by
`~/.bashrc` to populate the env var for interactive shells.
4. `${XDG_CONFIG_HOME:-~/.config}/claude/oauth-token` — XDG-compliant fallback.
5. `~/.claude/.credentials.json` — last-resort parse of `claudeAiOauth.accessToken`.
A `WARNING` is logged when this source is used because Claude Code refreshes
its access token in RAM and may not write back, so the file can be stale.

If none of the sources produce a non-empty token, askcc exits non-zero with an
error listing every location that was checked, **without** invoking `claude`.

Whitespace around file contents is stripped. Unreadable token files
(`PermissionError`) and malformed `.credentials.json` files emit a `WARNING` and
the chain continues to the next source.

### User Configuration File

Expand Down
26 changes: 15 additions & 11 deletions askcc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
validate_issue_readiness,
write_prompt_content,
)
from .runners import DEFAULT_RUNNER, RUNNER_REGISTRY, get_runner
from .runners import DEFAULT_RUNNER, RUNNER_REGISTRY, OAuthTokenNotFoundError, get_runner
from .settings import VALID_EFFORT_LEVELS, SupportedLanguage, configure_logging

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -286,16 +286,20 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901
effort_level = _resolve_effort(args.effort, config.effort)
max_thinking_tokens = _resolve_max_thinking_tokens(args.max_thinking_tokens, config.max_thinking_tokens)
try:
return_code, usage = runner.run(
prompt,
config=config,
issue_url=issue_url,
cwd=cwd,
effort_level=effort_level,
max_thinking_tokens=max_thinking_tokens,
disable_thinking=args.disable_thinking,
disable_adaptive_thinking=args.disable_adaptive_thinking,
)
try:
return_code, usage = runner.run(
prompt,
config=config,
issue_url=issue_url,
cwd=cwd,
effort_level=effort_level,
max_thinking_tokens=max_thinking_tokens,
disable_thinking=args.disable_thinking,
disable_adaptive_thinking=args.disable_adaptive_thinking,
)
except OAuthTokenNotFoundError as e:
logger.error("[%s] %s", issue_url, e) # noqa: TRY400
sys.exit(1)
finally:
# Clean up /tmp files created by _build_prompt (not user templates)
for f in prompt_tempfiles:
Expand Down
116 changes: 114 additions & 2 deletions askcc/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,27 @@
import os
import subprocess
from abc import ABC, abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING

from .settings import CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING, CLAUDE_ENV_DISABLE_THINKING, CLAUDE_ENV_MAX_THINKING_TOKENS

if TYPE_CHECKING:
from pathlib import Path

from .definitions import AgentConfig

logger = logging.getLogger(__name__)

DEBUG_OUTPUT_MAX_CHARS = 2000

OAUTH_TOKEN_ENV = "CLAUDE_CODE_OAUTH_TOKEN" # noqa: S105
OAUTH_TOKEN_FILE_ENV = "CLAUDE_OAUTH_TOKEN_FILE" # noqa: S105
CONVENTIONAL_TOKEN_FILE: Path = Path.home() / ".tokens" / ".claude-oauth-token"
CREDENTIALS_JSON_FILE: Path = Path.home() / ".claude" / ".credentials.json"


class OAuthTokenNotFoundError(RuntimeError):
"""Raised when no Claude OAuth token can be resolved from any source."""


class Runner(ABC):
"""Base class for task runners."""
Expand Down Expand Up @@ -55,6 +63,99 @@ def _frontmatter_cli_flags(config: AgentConfig) -> list[str]:
class ClaudeRunner(Runner):
"""Runs tasks via the Claude Code CLI."""

def _read_token_file(self, path: Path) -> str | None:
"""Read and strip a token file. Returns None on FileNotFoundError or empty content.

Logs a WARNING and returns None on PermissionError.
"""
try:
content = path.read_text()
except FileNotFoundError:
return None
except PermissionError as exc:
logger.warning("auth: cannot read token file %s: %s", path, exc)
return None
token = content.strip()
return token or None

def _read_credentials_json(self, path: Path) -> str | None:
"""Read ``claudeAiOauth.accessToken`` from the Claude Code credentials file.

Schema observed: ``{"claudeAiOauth": {"accessToken": "..."}}``.
Returns None if missing/empty; logs a WARNING and returns None on parse or schema errors.
"""
try:
content = path.read_text()
except FileNotFoundError:
return None
except PermissionError as exc:
logger.warning("auth: cannot read credentials file %s: %s", path, exc)
return None
try:
data = json.loads(content)
except json.JSONDecodeError as exc:
logger.warning("auth: failed to parse %s as JSON: %s", path, exc)
return None
try:
token = data["claudeAiOauth"]["accessToken"]
except (KeyError, TypeError) as exc:
logger.warning("auth: %s missing claudeAiOauth.accessToken: %s", path, exc)
return None
if not isinstance(token, str):
logger.warning("auth: %s claudeAiOauth.accessToken is not a string", path)
return None
token = token.strip()
return token or None

def _xdg_token_path(self) -> Path:
"""Compute the XDG-compliant Claude OAuth token path at call time."""
xdg_config_home = os.environ.get("XDG_CONFIG_HOME", "").strip()
base = Path(xdg_config_home) if xdg_config_home else Path.home() / ".config"
return base / "claude" / "oauth-token"

def _resolve_oauth_token(self) -> tuple[str, str]:
"""Resolve a Claude OAuth token from the discovery chain.

Returns ``(token, source_label)`` where ``source_label`` names the source that
produced the token. Raises :class:`OAuthTokenNotFoundError` when no source
yields a non-empty token.
"""
env_token = os.environ.get(OAUTH_TOKEN_ENV, "").strip()
if env_token:
return env_token, f"env {OAUTH_TOKEN_ENV}"

checked: list[str] = [f"env {OAUTH_TOKEN_ENV}"]

custom_path_str = os.environ.get(OAUTH_TOKEN_FILE_ENV, "").strip()
if custom_path_str:
custom_path = Path(custom_path_str).expanduser()
checked.append(f"env {OAUTH_TOKEN_FILE_ENV}={custom_path}")
token = self._read_token_file(custom_path)
if token:
return token, f"file {custom_path} (via {OAUTH_TOKEN_FILE_ENV})"
else:
checked.append(f"env {OAUTH_TOKEN_FILE_ENV} (unset)")

checked.append(f"file {CONVENTIONAL_TOKEN_FILE}")
conventional = self._read_token_file(CONVENTIONAL_TOKEN_FILE)
if conventional:
return conventional, f"file {CONVENTIONAL_TOKEN_FILE}"

xdg_path = self._xdg_token_path()
checked.append(f"file {xdg_path}")
xdg_token = self._read_token_file(xdg_path)
if xdg_token:
return xdg_token, f"file {xdg_path}"

checked.append(f"file {CREDENTIALS_JSON_FILE}")
credentials_token = self._read_credentials_json(CREDENTIALS_JSON_FILE)
if credentials_token:
return credentials_token, f"file {CREDENTIALS_JSON_FILE}"

locations = ", ".join(checked)
msg = f"no Claude credentials found in any of: {locations}"
raise OAuthTokenNotFoundError(msg)

def run(
self,
prompt: str,
Expand Down Expand Up @@ -88,6 +189,17 @@ def run(
env = os.environ.copy()
env.pop("CLAUDECODE", None)

token, source = self._resolve_oauth_token()
env[OAUTH_TOKEN_ENV] = token
if source != f"env {OAUTH_TOKEN_ENV}":
logger.info("[%s] auth: loaded %s from %s", issue_url, OAUTH_TOKEN_ENV, source)
if str(CREDENTIALS_JSON_FILE) in source:
logger.warning(
"[%s] auth: %s can be stale (Claude Code refreshes in RAM and may not write back)",
issue_url,
CREDENTIALS_JSON_FILE,
)

if max_thinking_tokens is not None:
env[CLAUDE_ENV_MAX_THINKING_TOKENS] = str(max_thinking_tokens)
if disable_thinking:
Expand Down
Loading
Loading