feat(desktop): warn when another install uses the same profile

Use the spawn ledger rather than a last-writer stamp. Record the canonical
profile home and require an exact live PID/create-time pair for warnings.
Keep the default ledger policy unchanged for process reapers.

Expose the advisory message through the existing status response. The desktop
shows a dismissible warning on its existing refresh cadence. Do not block
startup, redirect HERMES_HOME, or add a timer or lock.

Verified with real subprocess ledger tests and the status API. The targeted
Python run passed 27 tests. The desktop run passed 12 tests, TypeScript
checking, and lint. No full suite or packaged-app test was run.
This commit is contained in:
ethernet
2026-09-11 19:47:00 -04:00
parent 995a4617ac
commit 4092ac4dc9
7 changed files with 249 additions and 7 deletions

View File

@@ -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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NotificationStack } from '@/components/notifications'
import { getStatus } from '@/hermes' import { getStatus } from '@/hermes'
import { $setupReadyTick, notifySetupReady } from '@/store/live-sync' import { $setupReadyTick, notifySetupReady } from '@/store/live-sync'
import { clearNotifications } from '@/store/notifications'
import { deferred } from '../../../test/deferred' import { deferred } from '../../../test/deferred'
@@ -27,6 +30,7 @@ beforeEach(() => {
.mockReset() .mockReset()
.mockResolvedValue({} as never) .mockResolvedValue({} as never)
$setupReadyTick.set(0) $setupReadyTick.set(0)
clearNotifications()
}) })
afterEach(() => { afterEach(() => {
@@ -36,6 +40,45 @@ afterEach(() => {
}) })
describe('useStatusSnapshot', () => { 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 () => { it('pauses status RPCs while visible but unfocused, then catches up on focus', async () => {
vi.mocked(document.hasFocus).mockReturnValue(false) vi.mocked(document.hasFocus).mockReturnValue(false)
const requestGateway = vi.fn().mockResolvedValue({}) as unknown as GatewayRequester const requestGateway = vi.fn().mockResolvedValue({}) as unknown as GatewayRequester

View File

@@ -4,6 +4,7 @@ import { getStatus } from '@/hermes'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier' import { refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier'
import { $setupReadyTick } from '@/store/live-sync' import { $setupReadyTick } from '@/store/live-sync'
import { dismissNotification, notify } from '@/store/notifications'
import type { StatusResponse } from '@/types/hermes' import type { StatusResponse } from '@/types/hermes'
// Statusbar health is ambient chrome, not live data — nothing the user acts on // Statusbar health is ambient chrome, not live data — nothing the user acts on
@@ -24,6 +25,8 @@ export function useStatusSnapshot(
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
let timer: number | undefined let timer: number | undefined
let sharedProfileWarning = ''
let sharedProfileNoticeId: string | undefined
// Status and inference readiness belong to one backend. A source switch // Status and inference readiness belong to one backend. A source switch
// can keep gatewayState="open" throughout, so clear the previous source's // can keep gatewayState="open" throughout, so clear the previous source's
@@ -107,6 +110,17 @@ export function useStatusSnapshot(
if (statusResult.status === 'fulfilled') { if (statusResult.status === 'fulfilled') {
setStatusSnapshot(statusResult.value) 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 { } finally {
scheduleRefresh() scheduleRefresh()
@@ -139,6 +153,10 @@ export function useStatusSnapshot(
document.removeEventListener('visibilitychange', onReturn) document.removeEventListener('visibilitychange', onReturn)
window.removeEventListener('focus', onReturn) window.removeEventListener('focus', onReturn)
if (sharedProfileNoticeId) {
dismissNotification(sharedProfileNoticeId)
}
if (timer !== undefined) { if (timer !== undefined) {
window.clearTimeout(timer) window.clearTimeout(timer)
} }

View File

@@ -1328,6 +1328,7 @@ export interface PlatformStatus {
} }
export interface StatusResponse { export interface StatusResponse {
shared_profile_warning?: string
active_sessions: number active_sessions: number
config_path: string config_path: string
config_version: number config_version: number

View File

@@ -121,6 +121,7 @@ class LedgerEntry:
host: str = "" host: str = ""
port: Optional[int] = None port: Optional[int] = None
profile: str = "" profile: str = ""
hermes_home: str = ""
def _ledger_path() -> Path: 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 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.""" """True/False when provable; ``None`` when psutil can't say."""
try: try:
import psutil import psutil
except Exception: except Exception:
return None return None
try: 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: except psutil.NoSuchProcess:
return False return False
except Exception: 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 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. 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)) 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() 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 = _new_entry(os.getpid(), _process_create_time(), purpose, project_root, spawner_pid, spawner_create)
entry.hermes_home = hermes_home_key()
if detail: if detail:
try: try:
entry.host = str(detail.get("host") or "") 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) return _append_entry(entry)
def ledger_entries(*, project_root: Optional[Path] = None) -> list[dict]: def ledger_entries(
"""Live-verified ledger entries for THIS install (a corrupt ledger is quarantined, read as empty). *, 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 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 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 []
return [ return [
e for e in entries 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 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)
] ]

View File

@@ -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 ""

View File

@@ -439,6 +439,10 @@ async def get_status(profile: Optional[str] = None):
if install_id: if install_id:
status["install_id"] = 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) components = await _component_health(gateway)
status["components"] = components status["components"] = components
status["overall"] = ("ok" if all(item.get("status") == "ok" for item in components.values()) status["overall"] = ("ok" if all(item.get("status") == "ok" for item in components.values())

View File

@@ -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()