From 4f70a9045ae7651f547f5b92f75ace7a9e039377 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 19:15:47 +0000 Subject: [PATCH] fix(preflight): timeout hung adapter auth checks (OPS-002) Co-authored-by: Ravindra Kumar --- scripts/lib/adapter_preflight.py | 34 ++++++++- .../assets/substrate/lib/adapter_preflight.py | 34 ++++++++- .../assets/substrate/substrate-manifest.json | 2 +- tests/test_adapter_preflight.py | 74 +++++++++++++++++-- 4 files changed, 136 insertions(+), 8 deletions(-) diff --git a/scripts/lib/adapter_preflight.py b/scripts/lib/adapter_preflight.py index 4a7582a3..1fcdfb88 100644 --- a/scripts/lib/adapter_preflight.py +++ b/scripts/lib/adapter_preflight.py @@ -18,6 +18,25 @@ re.MULTILINE | re.DOTALL, ) +# Auth probes must not hang forever (OPS-002 / PREFLIGHT-01). Override via +# FLEET_ADAPTER_AUTH_TIMEOUT_S (seconds); non-positive or invalid values fall +# back to the default. +DEFAULT_AUTH_TIMEOUT_S = 30.0 +_AUTH_TIMEOUT_ENV = "FLEET_ADAPTER_AUTH_TIMEOUT_S" + + +def _auth_timeout_s(environ: Mapping[str, str] = os.environ) -> float: + raw = environ.get(_AUTH_TIMEOUT_ENV) + if raw is None or raw == "": + return DEFAULT_AUTH_TIMEOUT_S + try: + value = float(raw) + except ValueError: + return DEFAULT_AUTH_TIMEOUT_S + if value <= 0: + return DEFAULT_AUTH_TIMEOUT_S + return value + @dataclass(frozen=True) class Intent: @@ -124,7 +143,20 @@ def check( continue if entry.get("skip_if_intent") == intent_name: continue - result = run(shlex.split(command), capture_output=True, text=True, check=False) + timeout_s = _auth_timeout_s(environ) + try: + result = run( + shlex.split(command), + capture_output=True, + text=True, + check=False, + timeout=timeout_s, + ) + except subprocess.TimeoutExpired: + failures.append( + f"auth check timed out after {timeout_s:g}s: {command}" + ) + continue if result.returncode != 0: failures.append(f"auth check failed ({result.returncode}): {command}") diff --git a/skills/autonomous-fleet-core/assets/substrate/lib/adapter_preflight.py b/skills/autonomous-fleet-core/assets/substrate/lib/adapter_preflight.py index 4a7582a3..1fcdfb88 100644 --- a/skills/autonomous-fleet-core/assets/substrate/lib/adapter_preflight.py +++ b/skills/autonomous-fleet-core/assets/substrate/lib/adapter_preflight.py @@ -18,6 +18,25 @@ re.MULTILINE | re.DOTALL, ) +# Auth probes must not hang forever (OPS-002 / PREFLIGHT-01). Override via +# FLEET_ADAPTER_AUTH_TIMEOUT_S (seconds); non-positive or invalid values fall +# back to the default. +DEFAULT_AUTH_TIMEOUT_S = 30.0 +_AUTH_TIMEOUT_ENV = "FLEET_ADAPTER_AUTH_TIMEOUT_S" + + +def _auth_timeout_s(environ: Mapping[str, str] = os.environ) -> float: + raw = environ.get(_AUTH_TIMEOUT_ENV) + if raw is None or raw == "": + return DEFAULT_AUTH_TIMEOUT_S + try: + value = float(raw) + except ValueError: + return DEFAULT_AUTH_TIMEOUT_S + if value <= 0: + return DEFAULT_AUTH_TIMEOUT_S + return value + @dataclass(frozen=True) class Intent: @@ -124,7 +143,20 @@ def check( continue if entry.get("skip_if_intent") == intent_name: continue - result = run(shlex.split(command), capture_output=True, text=True, check=False) + timeout_s = _auth_timeout_s(environ) + try: + result = run( + shlex.split(command), + capture_output=True, + text=True, + check=False, + timeout=timeout_s, + ) + except subprocess.TimeoutExpired: + failures.append( + f"auth check timed out after {timeout_s:g}s: {command}" + ) + continue if result.returncode != 0: failures.append(f"auth check failed ({result.returncode}): {command}") diff --git a/skills/autonomous-fleet-core/assets/substrate/substrate-manifest.json b/skills/autonomous-fleet-core/assets/substrate/substrate-manifest.json index 4b447600..85789743 100644 --- a/skills/autonomous-fleet-core/assets/substrate/substrate-manifest.json +++ b/skills/autonomous-fleet-core/assets/substrate/substrate-manifest.json @@ -3,7 +3,7 @@ "files": { "emit_trace.py": "4425a4dd35f40b230eb566330bb2c28fd487892a717e440947c31327388805ad", "lib/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "lib/adapter_preflight.py": "c0c5a7a57840374b92cca95e7ea043ecfcd499ab2f27d01cedc53f0ed72b8ed5", + "lib/adapter_preflight.py": "4cfd48fb36ce2c0216c9d5112d76864c5710dcafe561e7942ae1aa059c150b24", "lib/analyze_cost.py": "e0359e9eabfc0c01dd5241bce8c1c124698da3a62a9fee9fbfcaa023a849b645", "lib/analyze_seat.py": "ee0faf086e679fd50315f2f4b0293590e8e73a0961fcad9388899902700d1c67", "lib/community_preflight.py": "94a6eb2d68397db7c0f8dad36d5ce88b40893f12fd40765a97d9aab0946f2d4c", diff --git a/tests/test_adapter_preflight.py b/tests/test_adapter_preflight.py index 814e92d0..e1c8e604 100644 --- a/tests/test_adapter_preflight.py +++ b/tests/test_adapter_preflight.py @@ -1,17 +1,23 @@ """Tests for adapter requires-block preflight checks.""" from __future__ import annotations +import subprocess +import sys from pathlib import Path from types import SimpleNamespace import pytest -import sys - ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) -from lib.adapter_preflight import Intent, activity_hooks_advisory, check, load_requires # noqa: E402 +from lib.adapter_preflight import ( # noqa: E402 + DEFAULT_AUTH_TIMEOUT_S, + Intent, + activity_hooks_advisory, + check, + load_requires, +) def _which_factory(found: set[str]): @@ -21,15 +27,29 @@ def which(binary: str) -> str | None: return which -def _runner(returncode: int, calls: list[list[str]]): +def _runner(returncode: int, calls: list[list[str]], *, timeout_s: float = DEFAULT_AUTH_TIMEOUT_S): def run(args: list[str], **kwargs): calls.append(args) - assert kwargs == {"capture_output": True, "text": True, "check": False} + assert kwargs == { + "capture_output": True, + "text": True, + "check": False, + "timeout": timeout_s, + } return SimpleNamespace(returncode=returncode) return run +def _timeout_runner(calls: list[list[str]], *, timeout_s: float = DEFAULT_AUTH_TIMEOUT_S): + def run(args: list[str], **kwargs): + calls.append(args) + assert kwargs["timeout"] == timeout_s + raise subprocess.TimeoutExpired(cmd=args, timeout=timeout_s) + + return run + + def test_wiring_only_skips_all_checks() -> None: failures = check( { @@ -151,6 +171,50 @@ def test_auth_entry_with_empty_check_reports_failure() -> None: assert calls == [] +def test_auth_timeout_reports_clear_failure_via_injected_run() -> None: + calls: list[list[str]] = [] + failures = check( + {"bins": [], "env": [], "auth": [{"check": "gh auth status"}]}, + Intent(scm=True), + which=_which_factory(set()), + run=_timeout_runner(calls), + environ={}, + ) + + assert failures == [ + f"auth check timed out after {DEFAULT_AUTH_TIMEOUT_S:g}s: gh auth status" + ] + assert calls == [["gh", "auth", "status"]] + + +def test_auth_timeout_honors_fleet_adapter_auth_timeout_env() -> None: + calls: list[list[str]] = [] + failures = check( + {"bins": [], "env": [], "auth": [{"check": "gh auth status"}]}, + Intent(scm=True), + which=_which_factory(set()), + run=_timeout_runner(calls, timeout_s=12.5), + environ={"FLEET_ADAPTER_AUTH_TIMEOUT_S": "12.5"}, + ) + + assert failures == ["auth check timed out after 12.5s: gh auth status"] + assert calls == [["gh", "auth", "status"]] + + +def test_auth_timeout_invalid_env_falls_back_to_default() -> None: + calls: list[list[str]] = [] + failures = check( + {"bins": [], "env": [], "auth": [{"check": "gh auth status"}]}, + Intent(scm=True), + which=_which_factory({"gh"}), + run=_runner(0, calls), + environ={"FLEET_ADAPTER_AUTH_TIMEOUT_S": "nope"}, + ) + + assert failures == [] + assert calls == [["gh", "auth", "status"]] + + def test_load_requires_reads_fenced_yaml_requires_block(tmp_path: Path) -> None: adapter = tmp_path / "adapter" adapter.mkdir()