diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts index f05979d306..b0f16e6312 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts @@ -1,8 +1,11 @@ -import { act, cleanup, renderHook } from '@testing-library/react' +import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { createElement } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NotificationStack } from '@/components/notifications' import { getStatus } from '@/hermes' import { $setupReadyTick, notifySetupReady } from '@/store/live-sync' +import { clearNotifications } from '@/store/notifications' import { deferred } from '../../../test/deferred' @@ -27,6 +30,7 @@ beforeEach(() => { .mockReset() .mockResolvedValue({} as never) $setupReadyTick.set(0) + clearNotifications() }) afterEach(() => { @@ -36,6 +40,45 @@ afterEach(() => { }) describe('useStatusSnapshot', () => { + it('shows a dismissible shared-profile warning on the existing status refresh', async () => { + const warning = 'Another installation is using this profile.' + const requestGateway = vi.fn().mockResolvedValue({}) as unknown as GatewayRequester + + render(createElement(NotificationStack)) + + const { rerender } = renderHook(({ scope }) => useStatusSnapshot('open', requestGateway, scope), { + initialProps: { scope: 'local-default' } + }) + + await flushAsync() + expect(screen.queryByText(warning)).toBeNull() + vi.mocked(getStatus).mockResolvedValue({ shared_profile_warning: warning } as never) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(screen.getByText(warning)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /dismiss/i })) + expect(screen.queryByText(warning)).toBeNull() + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(screen.queryByText(warning)).toBeNull() + + vi.mocked(getStatus).mockResolvedValue({ shared_profile_warning: '' } as never) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + vi.mocked(getStatus).mockResolvedValue({ shared_profile_warning: warning } as never) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(screen.getByText(warning)).toBeTruthy() + vi.mocked(getStatus).mockResolvedValue({ shared_profile_warning: '' } as never) + rerender({ scope: 'local-work' }) + await flushAsync() + expect(screen.queryByText(warning)).toBeNull() + }) + it('pauses status RPCs while visible but unfocused, then catches up on focus', async () => { vi.mocked(document.hasFocus).mockReturnValue(false) const requestGateway = vi.fn().mockResolvedValue({}) as unknown as GatewayRequester diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts index bf8b3b6b16..55249bc12f 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -4,6 +4,7 @@ import { getStatus } from '@/hermes' import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' import { refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier' import { $setupReadyTick } from '@/store/live-sync' +import { dismissNotification, notify } from '@/store/notifications' import type { StatusResponse } from '@/types/hermes' // Statusbar health is ambient chrome, not live data — nothing the user acts on @@ -24,6 +25,8 @@ export function useStatusSnapshot( useEffect(() => { let cancelled = false let timer: number | undefined + let sharedProfileWarning = '' + let sharedProfileNoticeId: string | undefined // Status and inference readiness belong to one backend. A source switch // can keep gatewayState="open" throughout, so clear the previous source's @@ -107,6 +110,17 @@ export function useStatusSnapshot( if (statusResult.status === 'fulfilled') { setStatusSnapshot(statusResult.value) + const warning = statusResult.value.shared_profile_warning || '' + + // Keep dismissal until the conflict clears. A new overlap can warn again. + if (warning !== sharedProfileWarning) { + if (sharedProfileNoticeId) { + dismissNotification(sharedProfileNoticeId) + } + + sharedProfileWarning = warning + sharedProfileNoticeId = warning ? notify({ kind: 'warning', message: warning }) : undefined + } } } finally { scheduleRefresh() @@ -139,6 +153,10 @@ export function useStatusSnapshot( document.removeEventListener('visibilitychange', onReturn) window.removeEventListener('focus', onReturn) + if (sharedProfileNoticeId) { + dismissNotification(sharedProfileNoticeId) + } + if (timer !== undefined) { window.clearTimeout(timer) } diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 9ed1ddab18..c4913f9052 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -1328,6 +1328,7 @@ export interface PlatformStatus { } export interface StatusResponse { + shared_profile_warning?: string active_sessions: number config_path: string config_version: number diff --git a/hermes_cli/process_identity.py b/hermes_cli/process_identity.py index 9dbe7e3aa9..5e3ced9085 100644 --- a/hermes_cli/process_identity.py +++ b/hermes_cli/process_identity.py @@ -121,6 +121,7 @@ class LedgerEntry: host: str = "" port: Optional[int] = None profile: str = "" + hermes_home: str = "" def _ledger_path() -> Path: @@ -174,14 +175,18 @@ def _same_incarnation(proc, create_time: Optional[float]) -> bool: return create_time is None or abs(float(proc.create_time()) - float(create_time)) < 2.0 -def _pid_alive_matches(pid: int, create_time: Optional[float]) -> Optional[bool]: +def _pid_alive_matches(pid: int, create_time: Optional[float], *, strict: bool = False) -> Optional[bool]: """True/False when provable; ``None`` when psutil can't say.""" try: import psutil except Exception: return None try: - return _same_incarnation(psutil.Process(int(pid)), create_time) + proc = psutil.Process(int(pid)) + if strict: + return (create_time is not None and proc.create_time() == create_time + and proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE) + return _same_incarnation(proc, create_time) except psutil.NoSuchProcess: return False except Exception: @@ -195,9 +200,12 @@ def register_self(purpose: str, *, project_root: Optional[Path] = None, detail: pruned on every write. ``detail`` may carry ``host``/``port``/``profile`` so the update pipeline can relaunch a manually-started serve with its real bind address. """ + from hermes_constants import hermes_home_key + tag = parse_spawn_tag(os.environ.get(SPAWN_ENV_VAR)) spawner_pid, spawner_create = (tag.spawner_pid, tag.spawner_create) if tag else _desktop_spawner_identity() entry = _new_entry(os.getpid(), _process_create_time(), purpose, project_root, spawner_pid, spawner_create) + entry.hermes_home = hermes_home_key() if detail: try: entry.host = str(detail.get("host") or "") @@ -299,8 +307,13 @@ def register_child(pid: int, purpose: str, *, project_root: Optional[Path] = Non return _append_entry(entry) -def ledger_entries(*, project_root: Optional[Path] = None) -> list[dict]: - """Live-verified ledger entries for THIS install (a corrupt ledger is quarantined, read as empty). +def ledger_entries( + *, project_root: Optional[Path] = None, all_installs: bool = False, verified_only: bool = False, +) -> list[dict]: + """Ledger entries for this install, or all installs when explicitly requested. + + ``verified_only`` requires an exact live PID/create-time pair. The default preserves + unknown processes for reapers, which must not mistake missing proof for a dead process. Entries whose ``(pid, create_time)`` no longer matches a live process are excluded (PID reuse reads as dead, thanks to the create-time pair). A corrupt ledger is quarantined and read as empty — identical @@ -314,9 +327,10 @@ def ledger_entries(*, project_root: Optional[Path] = None) -> list[dict]: return [] return [ e for e in entries - if e.get("install") == want_install + if (all_installs or e.get("install") == want_install) and isinstance(e.get("pid"), int) - and _pid_alive_matches(e["pid"], e.get("create_time")) is not False + and (_pid_alive_matches(e["pid"], e.get("create_time"), strict=True) is True + if verified_only else _pid_alive_matches(e["pid"], e.get("create_time")) is not False) ] diff --git a/hermes_cli/shared_profile_warning.py b/hermes_cli/shared_profile_warning.py new file mode 100644 index 0000000000..07d1e7757b --- /dev/null +++ b/hermes_cli/shared_profile_warning.py @@ -0,0 +1,24 @@ +"""Advisory only: simultaneous installs share profile data, not ownership.""" +from __future__ import annotations + +from pathlib import Path + +from hermes_cli.process_identity import install_id, ledger_entries +from hermes_constants import hermes_home_key + + +def shared_profile_warning(*, home: Path | None = None, project_root: Path | None = None) -> str: + """An old stamp or a profile name alone cannot prove concurrent use.""" + own_install = install_id(project_root) + home_key = hermes_home_key(home) + if any( + entry.get("install") and entry["install"] != own_install + and entry.get("hermes_home") == home_key + for entry in ledger_entries(all_installs=True, verified_only=True) + ): + return ( + "Another Hermes installation is using this profile. Both installations share " + "its settings and data, so changes can conflict. You can continue, or close " + "the other installation before making changes." + ) + return "" diff --git a/hermes_cli/web_routers/status.py b/hermes_cli/web_routers/status.py index 7b5b9bd26d..663cf87ed7 100644 --- a/hermes_cli/web_routers/status.py +++ b/hermes_cli/web_routers/status.py @@ -439,6 +439,10 @@ async def get_status(profile: Optional[str] = None): if install_id: status["install_id"] = install_id + # Advisory only. The message exposes no paths or process identities on this public probe. + from hermes_cli.shared_profile_warning import shared_profile_warning + status["shared_profile_warning"] = await run_in_threadpool(shared_profile_warning) + components = await _component_health(gateway) status["components"] = components status["overall"] = ("ok" if all(item.get("status") == "ok" for item in components.values()) diff --git a/tests/hermes_cli/test_shared_profile_warning.py b/tests/hermes_cli/test_shared_profile_warning.py new file mode 100644 index 0000000000..5e68d6e883 --- /dev/null +++ b/tests/hermes_cli/test_shared_profile_warning.py @@ -0,0 +1,138 @@ +"""Live subprocesses, real ledger I/O, and isolated profile homes.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path + +import pytest + +from hermes_cli import process_identity +from hermes_constants import hermes_home_key + + +@pytest.fixture +def homes(tmp_path, monkeypatch): + root = tmp_path / "home" + root.mkdir() + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + return root + + +@contextmanager +def running_install(home: Path, install: Path): + env = dict(os.environ, HERMES_HOME=str(home)) + for key in ("HERMES_SPAWN", "HERMES_PARENT_PID", "HERMES_PARENT_START_MARKER"): + env.pop(key, None) + script = """ +import sys +from pathlib import Path +from hermes_cli.process_identity import register_self +assert register_self('serve', project_root=Path(sys.argv[1])) +print('ready', flush=True) +sys.stdin.readline() +""" + child = subprocess.Popen( + [sys.executable, "-u", "-c", script, str(install)], + env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, + ) + try: + assert child.stdout.readline().strip() == "ready" + yield child + finally: + child.communicate("exit\n", timeout=15) + assert child.returncode == 0 + + +def test_warning_tracks_live_other_install_in_same_home(homes, tmp_path): + from hermes_cli.shared_profile_warning import shared_profile_warning + + current = tmp_path / "stable" + other = tmp_path / "canary" + assert shared_profile_warning(project_root=current) == "" + with running_install(homes, other) as child: + warning = shared_profile_warning(project_root=current) + assert warning and "profile" in warning.lower() + assert shared_profile_warning(project_root=other) == "" + entry = next(e for e in process_identity.ledger_entries(project_root=other) if e["pid"] == child.pid) + assert entry["hermes_home"] == hermes_home_key(homes) + assert shared_profile_warning(home=homes / "profiles" / "work", project_root=current) == "" + # A stale file is not proof of concurrent use. + assert process_identity._ledger_path().exists() + assert shared_profile_warning(project_root=current) == "" + work = homes / "profiles" / "work" + work.mkdir(parents=True) + with running_install(work, other): + assert shared_profile_warning(project_root=current) == "" + assert shared_profile_warning(home=work, project_root=current) + + +def test_status_surfaces_live_warning_without_host_details(homes, tmp_path, monkeypatch): + from fastapi.testclient import TestClient + from hermes_cli import web_server + + monkeypatch.setattr(web_server.app.state, "auth_required", True, raising=False) + client = TestClient(web_server.app) + assert client.get("/api/status").json().get("shared_profile_warning", "") == "" + with running_install(homes, tmp_path / "canary"): + response = client.get("/api/status") + assert response.status_code == 200 + warning = response.json().get("shared_profile_warning", "") + assert warning + assert str(homes) not in warning + assert str(tmp_path / "canary") not in warning + assert client.get("/api/status").json().get("shared_profile_warning", "") == "" + work = homes / "profiles" / "work" + work.mkdir(parents=True) + (work / "config.yaml").write_text("{}") + with running_install(work, tmp_path / "canary"): + assert client.get("/api/status").json().get("shared_profile_warning", "") == "" + response = client.get("/api/status?profile=work") + assert response.status_code == 200 + assert response.json().get("shared_profile_warning") + + +def test_warning_rejects_reused_or_unverifiable_process_identity(homes, tmp_path): + from hermes_cli.shared_profile_warning import shared_profile_warning + + current = tmp_path / "stable" + with running_install(homes, tmp_path / "canary"): + ledger = process_identity._ledger_path() + entries = json.loads(ledger.read_text()) + original = entries[0]["create_time"] + assert shared_profile_warning(project_root=current) + for bad_create in (original - 60, original - 0.5, None): + entries[0]["create_time"] = bad_create + ledger.write_text(json.dumps(entries)) + assert shared_profile_warning(project_root=current) == "" + entries[0]["create_time"] = original + entries[0].pop("hermes_home", None) + ledger.write_text(json.dumps(entries)) + assert shared_profile_warning(project_root=current) == "" + entries[0]["hermes_home"] = hermes_home_key(homes / "profiles" / "other") + ledger.write_text(json.dumps(entries)) + assert shared_profile_warning(project_root=current) == "" + entries[0]["hermes_home"] = hermes_home_key(homes) + ledger.write_text(json.dumps(entries)) + assert shared_profile_warning(project_root=current) + + # Even a valid record is not live proof if the process probe is unavailable. + from unittest.mock import patch + with patch.object(process_identity, "_pid_alive_matches", return_value=None): + assert shared_profile_warning(project_root=current) == "" + assert shared_profile_warning(project_root=current) + ledger.write_text("{broken") + assert shared_profile_warning(project_root=current) == "" + assert ledger.with_suffix(".json.corrupt").exists() + ledger.write_text(json.dumps(entries)) + assert shared_profile_warning(project_root=current) + ledger.unlink() + assert shared_profile_warning(project_root=current) == "" + assert not ledger.exists() + assert not (homes / "config.yaml").exists() + assert not (homes / "state.db").exists()