feat(cli): hard-stop on incompatible-version response header

Every API response is inspected via httpx event_hooks. When the server
reports X-Agnes-Min-Version > local, CLI prints a remediation message
and exits 2. Latest-version drift continues to be handled by the
update_check warning loop — no double-warning on every API call.
This commit is contained in:
ZdenekSrotyr 2026-05-06 15:33:22 +02:00
parent af2b866961
commit 2680a6724b
2 changed files with 106 additions and 1 deletions

View file

@ -3,7 +3,9 @@
import atexit import atexit
import glob import glob
import os import os
import platform
import re import re
import sys
import threading import threading
import time import time
import traceback import traceback
@ -15,6 +17,7 @@ from typing import Optional
import httpx import httpx
from cli.config import _config_dir, get_server_url, get_token from cli.config import _config_dir, get_server_url, get_token
from cli.update_check import _installed_version, _version_lt
# PID-suffixed tmp / part files — see `_download_chunked` and # PID-suffixed tmp / part files — see `_download_chunked` and
@ -213,6 +216,35 @@ def _translate_transport_error(
) )
def _check_version_headers(response: "httpx.Response") -> None:
"""Hard-stop the CLI when the server reports we're below min_version.
Drift warnings (`local < latest`) are already printed by the
update_check root callback in cli/main.py no need to nag again on
every API call. This hook only enforces the hard floor.
"""
# Recursion barrier: `agnes self-upgrade` sets this for the duration
# of the upgrade. Without it, a /api/* call inside the install flow
# could exit 2 with "Run: agnes self-upgrade" — inside agnes
# self-upgrade. The sentinel is process-local and propagates to
# subprocesses via the explicit env= passed to the smoke test.
if os.environ.get("AGNES_SELF_UPGRADE_IN_PROGRESS") == "1":
return
latest = response.headers.get("X-Agnes-Latest-Version")
minv = response.headers.get("X-Agnes-Min-Version")
if not latest or not minv:
return
local = _installed_version()
if local == "unknown":
return
if _version_lt(local, minv):
sys.stderr.write(
f"error: agnes {local} is incompatible with server {latest} "
f"(min required: {minv}). Run: agnes self-upgrade\n"
)
sys.exit(2)
def get_client(timeout: float = 30.0) -> httpx.Client: def get_client(timeout: float = 30.0) -> httpx.Client:
"""Get an authenticated httpx client. """Get an authenticated httpx client.
@ -220,6 +252,13 @@ def get_client(timeout: float = 30.0) -> httpx.Client:
`api_*` helpers (one request, then close). The big-stream path `api_*` helpers (one request, then close). The big-stream path
(`stream_download`) routes through `_get_shared_client()` to amortize (`stream_download`) routes through `_get_shared_client()` to amortize
TLS handshakes and HTTP/2 multiplexing across N parquet downloads. TLS handshakes and HTTP/2 multiplexing across N parquet downloads.
Wires `_check_version_headers` as a response event hook: every
metadata call sees the server's `X-Agnes-{Latest,Min}-Version`
headers and hard-stops if our local version is below the floor.
Hook is intentionally NOT wired on `_get_shared_client()` that
client backs streaming parquet downloads where a `sys.exit(2)`
mid-stream would leak per-thread part files.
""" """
token = get_token() token = get_token()
headers = {} headers = {}
@ -227,8 +266,9 @@ def get_client(timeout: float = 30.0) -> httpx.Client:
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
return httpx.Client( return httpx.Client(
base_url=get_server_url(), base_url=get_server_url(),
headers=headers, headers={**headers, "User-Agent": f"agnes/{_installed_version()} ({platform.system().lower()})"},
timeout=timeout, timeout=timeout,
event_hooks={"response": [_check_version_headers]},
) )

View file

@ -0,0 +1,65 @@
"""Verify cli/client.py:get_client() hard-stops on min_version mismatch."""
from unittest.mock import patch
import httpx
import pytest
def _fake_response(headers: dict) -> httpx.Response:
return httpx.Response(status_code=200, headers=headers, content=b"{}", request=httpx.Request("GET", "http://x/"))
def test_local_below_min_exits_with_code_2():
from cli.client import _check_version_headers
with patch("cli.client._installed_version", return_value="0.30.0"):
resp = _fake_response({
"X-Agnes-Latest-Version": "0.40.0",
"X-Agnes-Min-Version": "0.35.0",
})
with pytest.raises(SystemExit) as exc:
_check_version_headers(resp)
assert exc.value.code == 2
def test_local_at_or_above_min_does_not_exit():
from cli.client import _check_version_headers
with patch("cli.client._installed_version", return_value="0.40.0"):
resp = _fake_response({
"X-Agnes-Latest-Version": "0.40.0",
"X-Agnes-Min-Version": "0.35.0",
})
_check_version_headers(resp) # must not raise
def test_missing_headers_no_enforcement():
"""Older server without middleware → no headers → no-op."""
from cli.client import _check_version_headers
with patch("cli.client._installed_version", return_value="0.10.0"):
resp = _fake_response({}) # empty headers
_check_version_headers(resp) # must not raise
def test_unknown_local_version_no_enforcement():
"""Source-checkout / editable install → never block."""
from cli.client import _check_version_headers
with patch("cli.client._installed_version", return_value="unknown"):
resp = _fake_response({
"X-Agnes-Latest-Version": "0.40.0",
"X-Agnes-Min-Version": "0.35.0",
})
_check_version_headers(resp) # must not raise
def test_self_upgrade_in_progress_disables_enforcement(monkeypatch):
"""Recursion barrier: while self-upgrade runs, no /api/* call may
block on min-version drift. Otherwise an in-flight upgrade could
sys.exit(2) with 'Run: agnes self-upgrade' from inside itself."""
from cli.client import _check_version_headers
monkeypatch.setenv("AGNES_SELF_UPGRADE_IN_PROGRESS", "1")
with patch("cli.client._installed_version", return_value="0.10.0"):
resp = _fake_response({
"X-Agnes-Latest-Version": "0.40.0",
"X-Agnes-Min-Version": "0.35.0",
})
_check_version_headers(resp) # must not raise