From ac0f575d34c72c3b436b7c1bd5893bf07ed3113b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 15:49:59 -0700 Subject: [PATCH] [eric] health: boot-time bundled-CLI integrity check rides the login-health pill with repair guidance --- backend/apps/agents/agents.py | 9 +++-- .../apps/agents/core/bundled_cli_missing.py | 26 ++++++++++++ backend/tests/test_bundled_cli_missing.py | 40 +++++++++++++++++++ .../overlays/ProviderHealthToast.tsx | 15 ++++--- .../src/shared/state/subscriptionsSlice.ts | 15 +++++-- 5 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 backend/apps/agents/core/bundled_cli_missing.py create mode 100644 backend/tests/test_bundled_cli_missing.py diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index e1688c4c..1a32fe28 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -531,15 +531,18 @@ async def subscriptions_health(): so the frontend can retry once instead of reading 'all healthy' off a cold boot.""" from backend.apps.nine_router import is_running, get_providers from backend.apps.nine_router.subscription_health import probe_subscription_health + from backend.apps.agents.core.bundled_cli_missing import bundled_cli_missing + # Independent of the router: the bundled-CLI integrity check rides the same boot fetch so an AV-quarantined runtime surfaces as a pill instead of dead turns. + p_cli_missing = bundled_cli_missing() is not None if not is_running(): - return {"dead": [], "skipped": True} + return {"dead": [], "skipped": True, "cli_missing": p_cli_missing} try: connections = await get_providers() dead = await probe_subscription_health(connections) - return {"dead": dead, "skipped": False} + return {"dead": dead, "skipped": False, "cli_missing": p_cli_missing} except Exception as e: logger.debug(f"subscription health probe failed: {e}") - return {"dead": [], "skipped": True} + return {"dead": [], "skipped": True, "cli_missing": p_cli_missing} @agents.router.get("/subscriptions/models") diff --git a/backend/apps/agents/core/bundled_cli_missing.py b/backend/apps/agents/core/bundled_cli_missing.py new file mode 100644 index 00000000..794c8f33 --- /dev/null +++ b/backend/apps/agents/core/bundled_cli_missing.py @@ -0,0 +1,26 @@ +"""Detect the AV-quarantine failure class at boot: the claude-agent-sdk's bundled +CLI binary deleted out from under an installed app (Windows-only in field data; +22 of 25 affected installs never produced another agent reply). Returns the +expected path when the binary is gone so the health pill can tell the user how +to repair, None when it's present or there is nothing to verify.""" +import platform +from pathlib import Path +from typing import Optional +from typeguard import typechecked + + +@typechecked +def bundled_cli_missing() -> Optional[str]: + try: + import claude_agent_sdk + p_pkg = Path(claude_agent_sdk.__file__).parent + except Exception: + return None + p_name = "claude.exe" if platform.system() == "Windows" else "claude" + p_bundled = p_pkg / "_bundled" / p_name + # No _bundled dir at all = a source install running the PATH CLI; nothing to verify (quarantine removes the file, not the dir). + if not p_bundled.parent.is_dir(): + return None + if p_bundled.is_file(): + return None + return str(p_bundled) diff --git a/backend/tests/test_bundled_cli_missing.py b/backend/tests/test_bundled_cli_missing.py new file mode 100644 index 00000000..68904dba --- /dev/null +++ b/backend/tests/test_bundled_cli_missing.py @@ -0,0 +1,40 @@ +"""Pins the boot-time AV-quarantine detector: binary present = quiet, binary +deleted out of an intact _bundled dir = flagged with the expected path, no +_bundled dir at all (source installs) = quiet, so it can never cry wolf on dev.""" +import platform +import sys +import types + +from backend.apps.agents.core.bundled_cli_missing import bundled_cli_missing + +P_CLI_NAME = "claude.exe" if platform.system() == "Windows" else "claude" + + +def p_fake_sdk(monkeypatch, tmp_path, with_dir=True, with_binary=True): + pkg = tmp_path / "claude_agent_sdk" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + if with_dir: + (pkg / "_bundled").mkdir() + if with_binary: + (pkg / "_bundled" / P_CLI_NAME).write_text("stub") + mod = types.ModuleType("claude_agent_sdk") + mod.__file__ = str(pkg / "__init__.py") + monkeypatch.setitem(sys.modules, "claude_agent_sdk", mod) + + +def test_present_binary_is_quiet(monkeypatch, tmp_path): + p_fake_sdk(monkeypatch, tmp_path) + assert bundled_cli_missing() is None + + +def test_deleted_binary_is_flagged_with_path(monkeypatch, tmp_path): + p_fake_sdk(monkeypatch, tmp_path, with_binary=False) + result = bundled_cli_missing() + assert result is not None + assert result.endswith(P_CLI_NAME) + + +def test_no_bundled_dir_is_quiet(monkeypatch, tmp_path): + p_fake_sdk(monkeypatch, tmp_path, with_dir=False) + assert bundled_cli_missing() is None diff --git a/frontend/src/app/components/overlays/ProviderHealthToast.tsx b/frontend/src/app/components/overlays/ProviderHealthToast.tsx index 028157e0..d6ec1125 100644 --- a/frontend/src/app/components/overlays/ProviderHealthToast.tsx +++ b/frontend/src/app/components/overlays/ProviderHealthToast.tsx @@ -16,6 +16,7 @@ export default function ProviderHealthToast() { const dispatch = useAppDispatch(); const open = useAppSelector((s) => s.subscriptions.healthToastOpen); const dead = useAppSelector((s) => s.subscriptions.healthDead); + const cliMissing = useAppSelector((s) => s.subscriptions.healthCliMissing); const onReconnect = React.useCallback(() => { dispatch(openSettingsModal('models')); @@ -26,7 +27,7 @@ export default function ProviderHealthToast() { return ( 0} + open={open && (dead.length > 0 || cliMissing)} autoHideDuration={null} // Clickaway would kill the pill on the user's first canvas click, before they read it; only the X or Reconnect dismisses. onClose={(event, reason) => { if (reason !== 'clickaway') dispatch(hideProviderHealthToast()); }} @@ -43,9 +44,11 @@ export default function ProviderHealthToast() { }} action={ <> - + {dead.length > 0 && ( + + )} } > - Your {labels} login{dead.length > 1 ? 's have' : ' has'} expired; chats on {dead.length > 1 ? 'them' : 'it'} will fail until you reconnect. + {cliMissing + ? 'A core OpenSwarm component is missing, usually antivirus quarantine. Restore it from quarantine and add an exclusion for OpenSwarm, or reinstall from openswarm.com; agents cannot run until then.' + : `Your ${labels} login${dead.length > 1 ? 's have' : ' has'} expired; chats on ${dead.length > 1 ? 'them' : 'it'} will fail until you reconnect.`} ); diff --git a/frontend/src/shared/state/subscriptionsSlice.ts b/frontend/src/shared/state/subscriptionsSlice.ts index 388566ca..444b8917 100644 --- a/frontend/src/shared/state/subscriptionsSlice.ts +++ b/frontend/src/shared/state/subscriptionsSlice.ts @@ -25,6 +25,7 @@ export interface DeadProvider { export interface SubscriptionsState { status: SubscriptionStatus | null; healthDead: DeadProvider[]; + healthCliMissing: boolean; healthToastOpen: boolean; } @@ -34,6 +35,7 @@ type WithSubscriptions = { subscriptions: SubscriptionsState }; const initialState: SubscriptionsState = { status: null, healthDead: [], + healthCliMissing: false, healthToastOpen: false, }; @@ -56,9 +58,9 @@ export const fetchSubscriptionStatus = createAsyncThunk( /** Boot-time login-health check; `skipped` means the router wasn't up yet, caller may retry once. */ export const fetchProviderHealth = createAsyncThunk( 'subscriptions/fetchHealth', - async (): Promise<{ dead: DeadProvider[]; skipped: boolean }> => { + async (): Promise<{ dead: DeadProvider[]; skipped: boolean; cli_missing?: boolean }> => { const r = await fetch(`${API_BASE}/agents/subscriptions/health`); - return (await r.json()) as { dead: DeadProvider[]; skipped: boolean }; + return (await r.json()) as { dead: DeadProvider[]; skipped: boolean; cli_missing?: boolean }; }, ); @@ -99,9 +101,14 @@ const subscriptionsSlice = createSlice({ state.status = action.payload; }); builder.addCase(fetchProviderHealth.fulfilled, (state, action) => { - if (action.payload.skipped) return; + // cli_missing is filesystem truth, valid even when the router probe was skipped. + state.healthCliMissing = action.payload.cli_missing ?? false; + if (action.payload.skipped) { + state.healthToastOpen = state.healthToastOpen || state.healthCliMissing; + return; + } state.healthDead = action.payload.dead ?? []; - state.healthToastOpen = state.healthDead.length > 0; + state.healthToastOpen = state.healthDead.length > 0 || state.healthCliMissing; }); }, });