diff --git a/docs/agents/index.md b/docs/agents/index.md index 5612bcc..10691f6 100644 --- a/docs/agents/index.md +++ b/docs/agents/index.md @@ -261,6 +261,153 @@ vp run claude # or: vp c Credentials are stored in `~/.config/vibepod/agents/claude/`. On first run, Claude's interactive setup will guide you through API key configuration. +#### Long-lived token (recommended) + +Claude Code has a known upstream bug where OAuth access tokens (~8 h TTL) are not automatically refreshed from disk, forcing users to run `/login` roughly once per day. See [Why this workaround exists](#why-this-workaround-exists) below for the full bug history and links. + +VibePod works around this by storing a ~1-year long-lived token on the host and injecting it as `CLAUDE_CODE_OAUTH_TOKEN` on every run. This sidesteps the refresh path entirely. + +!!! info "This is an official authentication method" + `claude setup-token` and the `CLAUDE_CODE_OAUTH_TOKEN` environment variable are both documented by Anthropic as a supported authentication path for CI pipelines, scripts, and other environments where an interactive browser login isn't available. See the [official Claude Code authentication docs](https://code.claude.com/docs/en/authentication#long-lived-tokens) and the [`claude-code-action` setup guide](https://github.com/anthropics/claude-code-action/blob/main/docs/setup.md). VibePod just automates the storage and injection. + +**One-time setup:** + +```bash +vp run claude setup-token +``` + +This starts the container with `claude setup-token`, which opens Anthropic's OAuth flow in your browser. After you authorise, the container prints a token. VibePod then prompts you to paste it and saves it to: + +```text +~/.config/vibepod/agents/claude/oauth-token (mode 0600) +``` + +**Subsequent runs:** + +```bash +vp run claude +``` + +VibePod detects the stored token and injects `CLAUDE_CODE_OAUTH_TOKEN` automatically. Look for `Using stored Claude OAuth token` in the startup output to confirm. + +**Precedence** (first match wins): + +1. `-e ANTHROPIC_API_KEY=...` or `-e CLAUDE_CODE_OAUTH_TOKEN=...` passed on the CLI +2. `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` set in your per-agent `env:` config +3. Stored `oauth-token` file +4. Interactive OAuth via `.credentials.json` (subject to the refresh bug) + +**Verifying the token is stored:** + +```bash +vp doctor claude +``` + +Shows credentials state, stored-token presence and mtime, and which auth mode the next run will use. You can also inspect the file directly: + +```bash +ls -l ~/.config/vibepod/agents/claude/oauth-token +# or to view contents (treat as a secret — do not share): +nano ~/.config/vibepod/agents/claude/oauth-token +``` + +**Verifying the token works:** + +```bash +vp run claude -p "say ok" +``` + +`-p` runs Claude Code in headless mode — one API call, one response. If you see "ok", the token is valid. + +**Caveats:** + +- The long-lived token is **inference-only** — it cannot establish [Remote Control](https://code.claude.com/docs/en/remote-control) sessions (steering a container from claude.ai/code or the mobile app). +- `claude setup-token` requires a **Pro, Max, Team, or Enterprise** plan. Console (pay-per-token) accounts should use `ANTHROPIC_API_KEY` instead. +- The token rotates roughly once a year. When it expires, just run `vp run claude setup-token` again. + +#### Using an API key instead + +If you're on a Console (pay-per-token) account, set `ANTHROPIC_API_KEY` and skip the setup-token flow entirely: + +```bash +vp run claude -e ANTHROPIC_API_KEY=sk-ant-... +``` + +Or permanently in config: + +```yaml +agents: + claude: + env: + ANTHROPIC_API_KEY: sk-ant-... +``` + +#### Diagnostics + +`vp doctor claude` is the first tool to reach for when auth misbehaves. It reports: + +- `.credentials.json` — file owner/mode, `expiresAt`, presence of `refreshToken`, scopes, subscription type +- `.claude.json` — mtime cross-check +- Stored long-lived token state +- Which host env vars (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CONFIG_DIR`) are set +- **Effective auth mode** — what the next `vp run claude` will actually use + +Exit codes: `0` healthy, `1` config dir missing, `2` OAuth token expired (useful in scripts). + +#### Why this workaround exists + +The root cause is in Claude Code itself, not in VibePod. The OAuth `refreshToken` is stored in `.credentials.json` but never used: the access token is loaded from disk, sent as-is until it 401s, and nothing is written back when a refresh would have succeeded. The bug affects native Linux, WSL, macOS, and every container-based deployment equally. + +Community forensics ([#33995 comment](https://github.com/anthropics/claude-code/issues/33995#issuecomment-2718892341)): + +> Set `expiresAt` in `~/.claude/.credentials.json` to `Date.now()` to force expiry. Send a message — Claude processes it successfully, meaning the in-memory token refresh worked. Check `~/.claude/.credentials.json` afterward — file was never written. Conclusion: `refreshOAuthToken` succeeds and returns new tokens, but the credential store's `update()` is never called (or silently fails) after a successful refresh. The new token lives only in memory. Next session launch reads the stale expired token from disk and requires re-login. + +The community-validated workaround ([#24317 comment](https://github.com/anthropics/claude-code/issues/24317#issuecomment-2664923815)) is exactly what VibePod implements: + +> I worked around this using `claude setup-token` and then feeding it in as the `CLAUDE_CODE_OAUTH_TOKEN` environment variable. It skips all the "OAuth tokens invalidating each other", but has the downside that it doesn't allow `/usage`. + +`claude setup-token` itself is an **officially supported** Claude Code authentication path, documented for exactly this kind of non-interactive deployment. See Anthropic's [authentication guide](https://code.claude.com/docs/en/authentication#long-lived-tokens) and the [`claude-code-action` setup guide](https://github.com/anthropics/claude-code-action/blob/main/docs/setup.md) — the same mechanism used by Anthropic's own GitHub Action. + +**Upstream tracking issues — core bug (access-token not refreshed from disk):** + +| # | Status | Summary | +|---|---|---| +| [#50743](https://github.com/anthropics/claude-code/issues/50743) | open · `has repro` · `area:auth` | Newest and cleanest repro on headless Linux — `refreshToken` ignored | +| [#42904](https://github.com/anthropics/claude-code/issues/42904) | closed as duplicate | Canonical "daily re-login required for subscription users" report | +| [#40985](https://github.com/anthropics/claude-code/issues/40985) | open · `stale` | "Auth tokens expire too frequently" — confirms ~8 h TTL | +| [#33995](https://github.com/anthropics/claude-code/issues/33995) | closed not-planned | Best technical forensics (quoted above); proves write-back is the broken step | +| [#21765](https://github.com/anthropics/claude-code/issues/21765) | closed not-planned | First clear statement: "Claude Code doesn't use refresh tokens to get new access tokens" | +| [#12447](https://github.com/anthropics/claude-code/issues/12447) | open | OAuth expiry disrupts autonomous workflows; refresh token handling needed | +| [#37402](https://github.com/anthropics/claude-code/issues/37402) | open | `--print` / automation mode also affected | + +**Multi-session race condition** (why a shared `.credentials.json` across simultaneous sessions makes things worse): + +| # | Status | Summary | +|---|---|---| +| [#24317](https://github.com/anthropics/claude-code/issues/24317) | open · `has repro` · 18 comments | Canonical thread; documents refresh-token rotation and single-use semantics | +| [#48786](https://github.com/anthropics/claude-code/issues/48786) | closed as dup of #24317 | Independent reproduction | +| [#27933](https://github.com/anthropics/claude-code/issues/27933) | closed | Early race-condition report | +| [#45129](https://github.com/anthropics/claude-code/issues/45129) | closed as dup | Agent worktree subprocesses hit this constantly | + +**Container / headless specifically:** + +| # | Status | Summary | +|---|---|---| +| [#22066](https://github.com/anthropics/claude-code/issues/22066) | closed as duplicate | OAuth authentication not persisting in Docker | +| [#34917](https://github.com/anthropics/claude-code/issues/34917) | closed | OAuth "Redirect URI not supported" in headless/Docker | +| [#34141](https://github.com/anthropics/claude-code/issues/34141) | closed | Claude Code ignores `ANTHROPIC_API_KEY` when OAuth redirect fails in devcontainers | +| [#7100](https://github.com/anthropics/claude-code/issues/7100) | closed not-planned | Request for official headless-auth documentation | +| [#22992](https://github.com/anthropics/claude-code/issues/22992) | open | Feature request: RFC 8628 device-code flow for headless | + +**Proxy / Cloudflare interaction** (relevant if you run vibepod behind the built-in mitmproxy): + +| # | Status | Summary | +|---|---|---| +| [#47754](https://github.com/anthropics/claude-code/issues/47754) | open · `area:auth` · `platform:linux` | Cloudflare WAF blocks OAuth token refresh from headless Linux servers | +| [#33269](https://github.com/anthropics/claude-code/issues/33269) | open | Cloudflare challenge race during `auth login` / `setup-token` | + +**Anthropic's posture:** most reports are auto-closed as duplicates by a bot; the core issues (#21765, #33995) were closed as "not planned." A changelog line for Claude Code v2.1.44 mentioned "Fixed auth refresh errors" but users report the same behaviour on every later version (v2.1.62, 2.1.74, 2.1.116 observed). No committed fix has landed as of this writing. + ### Gemini (Google) ```bash diff --git a/src/vibepod/cli.py b/src/vibepod/cli.py index 3a7eaaa..83ebd1e 100644 --- a/src/vibepod/cli.py +++ b/src/vibepod/cli.py @@ -2,9 +2,12 @@ from __future__ import annotations +from pathlib import Path +from typing import Annotated + import typer -from vibepod.commands import config, list_cmd, logs, proxy, run, stop, update +from vibepod.commands import config, doctor, list_cmd, logs, proxy, run, stop, update from vibepod.constants import AGENT_SHORTCUTS, SUPPORTED_AGENTS app = typer.Typer( @@ -14,7 +17,10 @@ context_settings={"help_option_names": ["-h", "--help"]}, ) -app.command(name="run")(run.run) +app.command( + name="run", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +)(run.run) app.command(name="stop")(stop.stop) app.command(name="list")(list_cmd.list_agents) app.command(name="version")(update.version) @@ -22,15 +28,68 @@ app.add_typer(logs.app, name="logs") app.add_typer(config.app, name="config") app.add_typer(proxy.app, name="proxy") +app.add_typer(doctor.app, name="doctor") def _register_run_alias(command_name: str, agent_name: str) -> None: - def _alias(bound_agent: str = agent_name) -> None: - run.run(agent=bound_agent) + def _alias( + workspace: Annotated[ + Path, typer.Option("-w", "--workspace", help="Workspace directory") + ] = Path("."), + pull: Annotated[ + bool, typer.Option("--pull", help="Pull latest image before run") + ] = False, + detach: Annotated[ + bool, typer.Option("-d", "--detach", help="Run container in background") + ] = False, + env: Annotated[ + list[str] | None, + typer.Option("-e", "--env", help="Environment variable KEY=VALUE", show_default=False), + ] = None, + name: Annotated[ + str | None, typer.Option("--name", help="Custom container name") + ] = None, + network: Annotated[ + str | None, + typer.Option( + "--network", + help="Additional Docker network to connect the container to", + ), + ] = None, + paste_images: Annotated[ + bool, + typer.Option( + "--paste-images", + help="Enable image pasting via X11 clipboard (requires DISPLAY to be set)", + ), + ] = False, + ikwid: Annotated[ + bool, + typer.Option( + "--ikwid", + help="I Know What I'm Doing: enable auto-approval / skip permission prompts", + ), + ] = False, + ) -> None: + run.run( + agent=agent_name, + workspace=workspace, + pull=pull, + detach=detach, + env=env, + name=name, + network=network, + paste_images=paste_images, + ikwid=ikwid, + ) _alias.__name__ = f"alias_{command_name}" _alias.__doc__ = f"Alias for `vp run {agent_name}`." - app.command(command_name, hidden=True)(_alias) + app.command( + command_name, + hidden=True, + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, + )(_alias) @app.command("ui", hidden=True) diff --git a/src/vibepod/commands/doctor.py b/src/vibepod/commands/doctor.py new file mode 100644 index 0000000..5dd7bab --- /dev/null +++ b/src/vibepod/commands/doctor.py @@ -0,0 +1,222 @@ +"""Doctor subcommands — inspect agent auth state on the host.""" + +from __future__ import annotations + +import json +import os +import stat +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import typer + +from vibepod.core.agents import agent_config_dir +from vibepod.utils.console import console, error, success, warning + +app = typer.Typer(help="Inspect agent auth and config state") + + +def _format_mtime(path: Path) -> str: + try: + ts = path.stat().st_mtime + except OSError: + return "unknown" + dt = datetime.fromtimestamp(ts, tz=timezone.utc).astimezone() + age = time.time() - ts + if age < 60: + age_str = f"{int(age)}s ago" + elif age < 3600: + age_str = f"{int(age // 60)}m ago" + elif age < 86400: + age_str = f"{int(age // 3600)}h ago" + else: + age_str = f"{int(age // 86400)}d ago" + return f"{dt.strftime('%Y-%m-%d %H:%M:%S %z')} ({age_str})" + + +def _format_expiry(expires_at_ms: int) -> tuple[str, bool]: + """Return (human string, is_expired).""" + now_ms = int(time.time() * 1000) + delta_ms = expires_at_ms - now_ms + dt = datetime.fromtimestamp(expires_at_ms / 1000, tz=timezone.utc).astimezone() + when = dt.strftime("%Y-%m-%d %H:%M:%S %z") + if delta_ms <= 0: + return f"{when} (EXPIRED {abs(delta_ms) // 60000}m ago)", True + minutes = delta_ms // 60000 + if minutes < 60: + rel = f"in {minutes}m" + elif minutes < 1440: + rel = f"in {minutes // 60}h {minutes % 60}m" + else: + rel = f"in {minutes // 1440}d {(minutes % 1440) // 60}h" + return f"{when} ({rel})", False + + +def _file_ownership(path: Path) -> str: + try: + st = path.stat() + except OSError as exc: + return f"" + mode = stat.S_IMODE(st.st_mode) + return f"uid={st.st_uid} gid={st.st_gid} mode={oct(mode)}" + + +@app.command("claude") +def claude() -> None: + """Inspect Claude Code credential state for diagnosing auth/refresh issues.""" + cfg_dir = agent_config_dir("claude") + console.print(f"[bold]Claude config dir:[/bold] {cfg_dir}") + + if not cfg_dir.exists(): + error(f"Config dir does not exist: {cfg_dir}") + raise typer.Exit(1) + + creds_path = cfg_dir / ".credentials.json" + claude_json = cfg_dir / ".claude.json" + creds_expired = False + + console.print() + console.print("[bold].credentials.json[/bold]") + if not creds_path.exists(): + warning(f" missing: {creds_path}") + warning(" → run `/login` inside the container to create credentials") + else: + console.print(f" path: {creds_path}") + console.print(f" ownership: {_file_ownership(creds_path)}") + console.print(f" modified: {_format_mtime(creds_path)}") + + try: + data: dict[str, Any] = json.loads(creds_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + error(f" could not parse credentials: {exc}") + raise typer.Exit(1) from exc + + oauth = data.get("claudeAiOauth") or {} + if not oauth: + warning(" no 'claudeAiOauth' block — file may use a different auth scheme") + else: + access = oauth.get("accessToken") + refresh = oauth.get("refreshToken") + expires_at = oauth.get("expiresAt") + scopes = oauth.get("scopes") or oauth.get("scope") + subscription = oauth.get("subscriptionType") or oauth.get("subscription") + + console.print(f" accessToken: {'present' if access else 'MISSING'}") + console.print(f" refreshToken: {'present' if refresh else 'MISSING'}") + if scopes: + console.print(f" scopes: {scopes}") + if subscription: + console.print(f" subscription: {subscription}") + + if isinstance(expires_at, (int, float)): + pretty, creds_expired = _format_expiry(int(expires_at)) + label = "[red]" if creds_expired else "[green]" + console.print(f" expiresAt: {label}{pretty}[/]") + else: + warning(" expiresAt missing or not numeric") + + if not refresh: + warning( + " → no refreshToken present; Claude Code cannot rotate this " + "session and will require re-login after expiry" + ) + + console.print() + console.print("[bold].claude.json[/bold]") + if claude_json.exists(): + console.print(f" ownership: {_file_ownership(claude_json)}") + console.print(f" modified: {_format_mtime(claude_json)}") + else: + console.print(" not present") + + console.print() + console.print("[bold]Stored long-lived token[/bold]") + stored_path = cfg_dir / "oauth-token" + stored_token_present = False + if stored_path.exists(): + try: + stored_value = stored_path.read_text(encoding="utf-8").strip() + except OSError as exc: + warning(f" could not read: {exc}") + stored_value = "" + if stored_value: + stored_token_present = True + console.print(f" path: {stored_path}") + console.print(f" ownership: {_file_ownership(stored_path)}") + console.print(f" modified: {_format_mtime(stored_path)}") + console.print(f" length: {len(stored_value)} chars") + else: + console.print(f" {stored_path} is empty") + else: + console.print(" not present — run `vp run claude setup-token` to create one") + + console.print() + console.print("[bold]Host environment overrides[/bold]") + found_any = False + for key in ( + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CONFIG_DIR", + ): + value = os.environ.get(key) + if value: + found_any = True + masked = f"set (len={len(value)})" if "KEY" in key or "TOKEN" in key else value + console.print(f" {key}: {masked}") + if not found_any: + console.print(" none set on host") + console.print( + " [dim]note: these are host-side; the container sees its own env.[/dim]" + ) + + console.print() + console.print("[bold]Effective auth mode on next `vp run claude`[/bold]") + if os.environ.get("ANTHROPIC_API_KEY"): + console.print(" [green]ANTHROPIC_API_KEY[/green] (passed from host env)") + elif os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"): + console.print(" [green]CLAUDE_CODE_OAUTH_TOKEN[/green] (passed from host env)") + elif stored_token_present: + console.print(" [green]stored long-lived token[/green] (no refresh needed)") + elif creds_path.exists(): + console.print( + " [yellow]OAuth credentials.json[/yellow] " + "(subject to the known refresh bug — may require /login when expired)" + ) + else: + console.print( + " [red]no auth[/red] — run `vp run claude` and `/login`, " + "or `vp run claude setup-token`" + ) + + console.print() + console.print("[bold]Tips[/bold]") + console.print( + " • If `modified` on .credentials.json never updates past the original /login time," + ) + console.print(" the token is not being rotated. Re-run with:") + console.print( + " [cyan]vp run claude -e ANTHROPIC_LOG=debug -e DEBUG=1[/cyan]" + ) + console.print( + " and look for [dim][API:auth][/dim] entries near/after expiry to confirm." + ) + console.print( + " • For headless/CI, consider `claude setup-token` + " + "`-e CLAUDE_CODE_OAUTH_TOKEN=...` to bypass refresh entirely." + ) + + # Exit 2 only if credentials.json is expired AND nothing else would auth: + # no env override, no stored token. If a stored token is present, the + # expired OAuth file doesn't matter for the next run. + effective_auth_broken = ( + creds_expired + and not stored_token_present + and not os.environ.get("ANTHROPIC_API_KEY") + and not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") + ) + if effective_auth_broken: + raise typer.Exit(2) + + success("doctor check complete") diff --git a/src/vibepod/commands/run.py b/src/vibepod/commands/run.py index f458c3c..6d593b4 100644 --- a/src/vibepod/commands/run.py +++ b/src/vibepod/commands/run.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Annotated, Any +import click import typer from rich.prompt import Confirm, Prompt @@ -28,6 +29,38 @@ from vibepod.core.session_logger import SessionLogger from vibepod.utils.console import error, info, success, warning +CLAUDE_TOKEN_FILENAME = "oauth-token" + + +def _claude_stored_token_path(config_dir: Path) -> Path: + return config_dir / CLAUDE_TOKEN_FILENAME + + +def _read_claude_stored_token(config_dir: Path) -> str | None: + path = _claude_stored_token_path(config_dir) + try: + token = path.read_text(encoding="utf-8").strip() + except FileNotFoundError: + return None + except OSError as exc: + warning(f"Could not read stored claude token at {path}: {exc}") + return None + return token or None + + +def _write_claude_stored_token(config_dir: Path, token: str) -> Path: + path = _claude_stored_token_path(config_dir) + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + # fchmod overrides umask; os.open mode alone is umask-filtered + os.fchmod(fd, 0o600) + except OSError: + warning(f"Could not restrict permissions on {path}; token may be readable by other users") + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(token.strip() + "\n") + return path + def _parse_env_pairs(values: list[str]) -> dict[str, str]: parsed: dict[str, str] = {} @@ -245,7 +278,15 @@ def run( ), ] = False, ) -> None: - """Start an agent container.""" + """Start an agent container. + + Any trailing arguments after the agent name are forwarded to the agent's + command inside the container, e.g. `vp run claude setup-token`. + """ + click_ctx = click.get_current_context(silent=True) + passthrough_args: list[str] = ( + list(click_ctx.args) if click_ctx is not None and click_ctx.args else [] + ) config = get_config() selected_agent_input = agent or str(config.get("default_agent", "claude")) selected_agent = resolve_agent_name(selected_agent_input) @@ -301,6 +342,17 @@ def run( **_parse_env_pairs(env or []), } + if ( + selected_agent == "claude" + and "CLAUDE_CODE_OAUTH_TOKEN" not in merged_env + and "ANTHROPIC_API_KEY" not in merged_env + and "setup-token" not in passthrough_args + ): + stored_token = _read_claude_stored_token(agent_config_dir(selected_agent)) + if stored_token: + merged_env["CLAUDE_CODE_OAUTH_TOKEN"] = stored_token + info("Using stored Claude OAuth token (from `vp run claude setup-token`)") + llm_cfg = config.get("llm", {}) llm_command_extra: list[str] = [] if llm_cfg.get("enabled") and spec.llm_env_map: @@ -367,6 +419,15 @@ def run( if llm_command_extra: command = list(command or []) + llm_command_extra + if passthrough_args: + if command is None: + try: + command = manager.resolve_launch_command(image=image, command=spec.command) + except DockerClientError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + command = list(command or []) + passthrough_args + config_dir = agent_config_dir(selected_agent) config_dir.mkdir(parents=True, exist_ok=True) @@ -484,6 +545,13 @@ def run( ) if detach: + if selected_agent == "claude" and "setup-token" in passthrough_args: + error( + "Cannot use --detach with `claude setup-token`: " + "the setup-token flow requires an interactive session." + ) + container.stop(timeout=5) + raise typer.Exit(1) success(f"Started {container.name}") return @@ -517,3 +585,74 @@ def run( raise finally: logger.close_session(exit_reason) + + if ( + selected_agent == "claude" + and "setup-token" in passthrough_args + and exit_reason == "normal" + ): + _capture_claude_setup_token(config_dir) + + +def _read_masked_line(prompt: str) -> str: + """Read a line from stdin echoing '*' for each character. + + Falls back to plain readline (no echo) on non-TTY stdin or platforms + without termios (e.g., Windows). + """ + sys.stdout.write(prompt) + sys.stdout.flush() + if not sys.stdin.isatty(): + return sys.stdin.readline().rstrip("\r\n") + try: + import termios + import tty + except ImportError: + from getpass import getpass + + return getpass(prompt="") + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + buf: list[str] = [] + try: + tty.setraw(fd) + while True: + ch = sys.stdin.read(1) + if ch in ("\r", "\n"): + break + if ch == "\x03": # Ctrl-C + raise KeyboardInterrupt + if ch in ("\x7f", "\b"): # backspace / delete + if buf: + buf.pop() + sys.stdout.write("\b \b") + sys.stdout.flush() + continue + if ch < " ": # other control chars — ignore + continue + buf.append(ch) + sys.stdout.write("*") + sys.stdout.flush() + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + sys.stdout.write("\n") + sys.stdout.flush() + return "".join(buf) + + +def _capture_claude_setup_token(config_dir: Path) -> None: + """Prompt the user to paste the token printed by `claude setup-token`.""" + info("") + info("Paste the long-lived token printed by `claude setup-token` above.") + info("It will be saved to the host so `vp run claude` can reuse it.") + token = _read_masked_line("Token: ").strip() + if not token: + warning("No token entered; nothing saved.") + return + try: + path = _write_claude_stored_token(config_dir, token) + except OSError as exc: + error(f"Could not write stored token: {exc}") + raise typer.Exit(1) from exc + success(f"Saved Claude OAuth token to {path} ({len(token)} chars)") + info("Future `vp run claude` invocations will use it automatically.") diff --git a/tests/test_claude_token.py b/tests/test_claude_token.py new file mode 100644 index 0000000..24ad24f --- /dev/null +++ b/tests/test_claude_token.py @@ -0,0 +1,44 @@ +"""Tests for the claude long-lived token storage + injection.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from vibepod.commands import run as run_cmd + + +def test_read_missing_token(tmp_path: Path) -> None: + assert run_cmd._read_claude_stored_token(tmp_path) is None + + +def test_write_and_read_token(tmp_path: Path) -> None: + path = run_cmd._write_claude_stored_token(tmp_path, " sk-abc123 \n") + assert path == tmp_path / "oauth-token" + assert path.read_text(encoding="utf-8") == "sk-abc123\n" + assert oct(path.stat().st_mode)[-3:] == "600" + assert run_cmd._read_claude_stored_token(tmp_path) == "sk-abc123" + + +def test_empty_token_file_is_none(tmp_path: Path) -> None: + (tmp_path / "oauth-token").write_text(" \n", encoding="utf-8") + assert run_cmd._read_claude_stored_token(tmp_path) is None + + +def test_token_filename_matches_doctor(tmp_path: Path) -> None: + """The filename written by run.py must match what doctor.py inspects.""" + path = run_cmd._write_claude_stored_token(tmp_path, "sk-abc") + # doctor.py reads `/oauth-token` directly + assert path.name == "oauth-token" + + +@pytest.mark.skipif(not hasattr(os, "fchmod"), reason="fchmod not available") +def test_write_token_permissions_ignore_umask(tmp_path: Path) -> None: + old_umask = os.umask(0o177) + try: + path = run_cmd._write_claude_stored_token(tmp_path, "sk-umask-test") + finally: + os.umask(old_umask) + assert oct(path.stat().st_mode)[-3:] == "600" diff --git a/tests/test_cli.py b/tests/test_cli.py index b436374..d333ea3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,13 +23,36 @@ def test_version() -> None: def test_full_agent_name_alias_runs_agent(monkeypatch) -> None: - called: dict[str, str | None] = {"agent": None} + called: dict[str, object] = {"agent": None, "passthrough": None} def _fake_run(agent=None, **kwargs) -> None: # noqa: ANN001, ANN003, ARG001 + import click + + ctx = click.get_current_context(silent=True) called["agent"] = agent + called["passthrough"] = list(ctx.args) if ctx and ctx.args else [] monkeypatch.setattr(run_cmd, "run", _fake_run) result = runner.invoke(app, ["claude"]) assert result.exit_code == 0 assert called["agent"] == "claude" + assert called["passthrough"] == [] + + +def test_alias_forwards_extra_args(monkeypatch) -> None: + called: dict[str, object] = {"agent": None, "passthrough": None} + + def _fake_run(agent=None, **kwargs) -> None: # noqa: ANN001, ANN003, ARG001 + import click + + ctx = click.get_current_context(silent=True) + called["agent"] = agent + called["passthrough"] = list(ctx.args) if ctx and ctx.args else [] + + monkeypatch.setattr(run_cmd, "run", _fake_run) + + result = runner.invoke(app, ["claude", "setup-token"]) + assert result.exit_code == 0 + assert called["agent"] == "claude" + assert called["passthrough"] == ["setup-token"] diff --git a/tests/test_config.py b/tests/test_config.py index 50588b9..a9a7c04 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -142,6 +142,9 @@ def test_per_agent_auto_pull_override(monkeypatch, tmp_path: Path) -> None: def test_default_config_includes_llm_section(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.chdir(tmp_path) + for var in ("VP_LLM_ENABLED", "VP_LLM_BASE_URL", "VP_LLM_API_KEY", "VP_LLM_MODEL"): + monkeypatch.delenv(var, raising=False) config = get_config() llm = config.get("llm") assert isinstance(llm, dict) diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..13bc6f3 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,131 @@ +"""Doctor command smoke tests.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +from typer.testing import CliRunner + +from vibepod.cli import app + +runner = CliRunner() + + +def test_doctor_missing_dir(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr( + "vibepod.commands.doctor.agent_config_dir", + lambda _agent: tmp_path / "does-not-exist", + ) + result = runner.invoke(app, ["doctor", "claude"]) + assert result.exit_code == 1 + + +def test_doctor_valid_token(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr( + "vibepod.commands.doctor.agent_config_dir", lambda _agent: tmp_path + ) + future_ms = int((time.time() + 3600) * 1000) + (tmp_path / ".credentials.json").write_text( + json.dumps( + { + "claudeAiOauth": { + "accessToken": "a", + "refreshToken": "r", + "expiresAt": future_ms, + "scopes": ["user:inference"], + } + } + ) + ) + result = runner.invoke(app, ["doctor", "claude"]) + assert result.exit_code == 0 + assert "refreshToken: present" in result.stdout + assert "accessToken: present" in result.stdout + + +def test_doctor_expired_token(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr( + "vibepod.commands.doctor.agent_config_dir", lambda _agent: tmp_path + ) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + past_ms = int((time.time() - 3600) * 1000) + (tmp_path / ".credentials.json").write_text( + json.dumps( + { + "claudeAiOauth": { + "accessToken": "a", + "refreshToken": "r", + "expiresAt": past_ms, + } + } + ) + ) + result = runner.invoke(app, ["doctor", "claude"]) + assert result.exit_code == 2 + assert "EXPIRED" in result.stdout + + +def test_doctor_expired_creds_but_stored_token_is_ok(tmp_path: Path, monkeypatch) -> None: + """Expired credentials.json should NOT exit 2 when a stored token covers auth.""" + monkeypatch.setattr( + "vibepod.commands.doctor.agent_config_dir", lambda _agent: tmp_path + ) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + past_ms = int((time.time() - 3600) * 1000) + (tmp_path / ".credentials.json").write_text( + json.dumps( + {"claudeAiOauth": {"accessToken": "a", "expiresAt": past_ms}} + ) + ) + (tmp_path / "oauth-token").write_text("sk-stored\n", encoding="utf-8") + result = runner.invoke(app, ["doctor", "claude"]) + assert result.exit_code == 0 + assert "stored long-lived token" in result.stdout + + +def test_doctor_missing_refresh_token(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr( + "vibepod.commands.doctor.agent_config_dir", lambda _agent: tmp_path + ) + future_ms = int((time.time() + 3600) * 1000) + (tmp_path / ".credentials.json").write_text( + json.dumps( + { + "claudeAiOauth": { + "accessToken": "a", + "expiresAt": future_ms, + } + } + ) + ) + result = runner.invoke(app, ["doctor", "claude"]) + assert result.exit_code == 0 + assert "refreshToken: MISSING" in result.stdout + + +def test_doctor_reports_stored_token_mode(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr( + "vibepod.commands.doctor.agent_config_dir", lambda _agent: tmp_path + ) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + (tmp_path / "oauth-token").write_text("sk-xyz\n", encoding="utf-8") + result = runner.invoke(app, ["doctor", "claude"]) + assert result.exit_code == 0 + assert "stored long-lived token" in result.stdout + + +def test_doctor_reports_host_env_mode(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr( + "vibepod.commands.doctor.agent_config_dir", lambda _agent: tmp_path + ) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "host-token-abc") + result = runner.invoke(app, ["doctor", "claude"]) + assert result.exit_code == 0 + assert "CLAUDE_CODE_OAUTH_TOKEN" in result.stdout + assert "passed from host env" in result.stdout