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.
121 lines
4.2 KiB
Python
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")
|