agnes-the-ai-analyst/cli/config.py
ZdenekSrotyr 8784f10a6b fix(devin-review): stale-token override + status sessions counter + lock comment
Three Devin Review findings on PR #173 addressed in one commit since
they're in adjacent code paths:

1. cli/commands/init.py:99 (\u{1F534}): `agnes init --token NEW` ran
   step 2 verify against the OLD on-disk token because `get_token()`
   read `~/.config/agnes/token.json` before the env var, and
   `_override_server_env` only set the env var. So `agnes init --force`
   on a machine with a stale token.json failed 401 with a confusing
   'token expired' even though the --token arg was valid.

   Fix: ContextVar-based override in `cli.config._token_override`
   checked by `get_token()` BEFORE the on-disk read.
   `_with_token_override` context manager scopes the override.
   `_override_server_env` now also sets the contextvar via
   `_with_token_override(token)`, so both env var and contextvar
   carry the override (env for back-compat with anything bypassing
   get_token; contextvar is the authoritative source).
   Async-safe (each task sees its own override) and leak-proof
   (resets on context exit).
   2 new tests: regression on stale-disk-token + scope leak guard.

2. cli/commands/status.py:43 (\u{1F7E1}): sessions_pending_upload only
   checked legacy `<workspace>/user/sessions/` and always reported 0
   in workspaces bootstrapped with `agnes init` (Claude Code writes
   to `~/.claude/projects/`, not the legacy path). Same bug we fixed
   for `agnes push` in 08e49591.

   Fix: route through `cli.lib.claude_sessions.list_session_files()`
   so status and push agree on what counts as a pending session.

3. connectors/bigquery/extractor.py:111 (\u{1F7E1}): docstring claimed
   "a live holder still wins the second flock attempt" — incorrect on
   Linux. After `unlink()` + `open()`, the new file is a new inode;
   fcntl.flock keys per-inode, so the old holder's lock does NOT block
   the new acquisition. In a genuine TTL-overrun scenario two writers
   CAN race the parquet.tmp.

   Fix: documentation only. Comment now honestly describes the
   inode-recreation behavior, names the threading.Lock as the actual
   in-process guard, and flags pid-gating as the next-iteration fix
   if real corruption surfaces. The 24h default TTL is well above
   typical COPY durations so the practical risk is low.

Tests: 17/17 across test_cli_init.py + test_lib_pull.py + the broader
regression set.
2026-05-04 21:26:30 +02:00

121 lines
4.2 KiB
Python

"""CLI configuration — token storage, server URL, sync state."""
import json
import os
from contextlib import contextmanager
from contextvars import ContextVar
from pathlib import Path
from typing import Iterator, Optional
# In-process override for `get_token()`. Used by `agnes init --token X` and
# `agnes auth import-token` to force a specific token for the duration of a
# scoped block, EVEN WHEN `~/.config/agnes/token.json` already holds a
# different (possibly stale) token. Without this override, `get_token()`
# reads the on-disk token first and the explicit `--token` argument is
# silently ignored — the bug Devin Review caught at cli/commands/init.py:99.
#
# A ContextVar is used (not a plain global) so concurrent callers — async
# tasks, threads — each see their own override, and a leaked override in
# one task can't corrupt another. `_token_override.set(...)` returns a
# token used to reset; the `_with_token_override` context manager scopes it.
_token_override: ContextVar[Optional[str]] = ContextVar(
"agnes_cli_token_override", default=None,
)
@contextmanager
def _with_token_override(token: Optional[str]) -> Iterator[None]:
"""Set `_token_override` for the duration of the block.
`get_token()` checks the override BEFORE reading `token.json`, so any
in-block call returns the supplied token regardless of on-disk state.
Restores the prior override (if any) on exit so nested overrides nest
correctly.
"""
if not token:
yield
return
reset_token = _token_override.set(token)
try:
yield
finally:
_token_override.reset(reset_token)
def _config_dir() -> Path:
d = Path(os.environ.get("AGNES_CONFIG_DIR", os.path.expanduser("~/.config/agnes")))
d.mkdir(parents=True, exist_ok=True)
return d
def get_server_url() -> str:
config = load_config()
return os.environ.get("AGNES_SERVER", config.get("server", "http://localhost:8000"))
def get_token() -> Optional[str]:
# In-process override wins over BOTH the on-disk file and the env var.
# Set by `_with_token_override(...)`; used by `agnes init --token X`
# to force the explicit arg through the verify call even when a stale
# `~/.config/agnes/token.json` exists.
if (override := _token_override.get()) is not None:
return override
token_file = _config_dir() / "token.json"
if token_file.exists():
data = json.loads(token_file.read_text(encoding="utf-8"))
return data.get("access_token")
return os.environ.get("AGNES_TOKEN")
def save_token(token: str, email: str, role: Optional[str] = None):
"""Persist token + email to ~/.config/agnes/token.json.
The ``role`` parameter is accepted for back-compat with older callers
but is no longer written — authorization derives from group memberships
server-side, not from a CLI-cached label. Old token.json files with a
``role`` field are still readable; the field is simply ignored.
"""
token_file = _config_dir() / "token.json"
token_file.write_text(json.dumps({
"access_token": token,
"email": email,
}, indent=2), encoding="utf-8")
def clear_token():
token_file = _config_dir() / "token.json"
if token_file.exists():
token_file.unlink()
def load_config() -> dict:
config_file = _config_dir() / "config.yaml"
if config_file.exists():
import yaml
return yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
return {}
def get_sync_state() -> dict:
state_file = _config_dir() / "sync_state.json"
if state_file.exists():
return json.loads(state_file.read_text(encoding="utf-8"))
return {}
def save_sync_state(state: dict):
state_file = _config_dir() / "sync_state.json"
state_file.write_text(json.dumps(state, indent=2), encoding="utf-8")
def save_config(data: dict):
"""Persist server URL and other config to config.yaml."""
import yaml
config_file = _config_dir() / "config.yaml"
existing = {}
if config_file.exists():
existing = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
existing.update(data)
config_file.write_text(yaml.dump(existing, default_flow_style=False), encoding="utf-8")