mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] health: boot-time bundled-CLI integrity check rides the login-health pill with repair guidance
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 (
|
||||
<Snackbar
|
||||
open={open && dead.length > 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={
|
||||
<>
|
||||
<Button size="small" onClick={onReconnect} sx={{ color: c.accent.primary, fontWeight: 700 }}>
|
||||
Reconnect
|
||||
</Button>
|
||||
{dead.length > 0 && (
|
||||
<Button size="small" onClick={onReconnect} sx={{ color: c.accent.primary, fontWeight: 700 }}>
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Dismiss"
|
||||
@@ -57,7 +60,9 @@ export default function ProviderHealthToast() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
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.`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user