Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/desktop/electron/venv-blocker-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,45 @@ describe('parseVenvBlockerScanOutput', () => {
assert.equal(o.kind, 'blocked')
})

// Contract fixture (#98336/#98350): the scanner reports exemption
// diagnostics (counts + sanitized evidence) alongside the authoritative
// blocked/processes fields. The consumer must tolerate those fields today
// and must keep enforcing blocked/processes consistency — a future parser
// change that either chokes on the diagnostics or silently reinterprets
// an exemption as a blocker breaks this fixture.
it('tolerates exemption diagnostics while enforcing blocked/processes consistency', () => {
const clear = parseVenvBlockerScanOutput(
ok({
pausable_gateways: 2,
ledgered_manual_serves: 1,
exempted_manual_serves: [{ pid: 78, purpose: 'serve', port: 9119 }]
})
)

assert.equal(clear.kind, 'clear')

const blocked = parseVenvBlockerScanOutput(
ok({
blocked: true,
processes: [{ pid: 79, name: 'python.exe', cmdline: 'c' }],
pausable_gateways: 1,
ledgered_manual_serves: 1,
exempted_manual_serves: [{ pid: 78, purpose: 'serve', port: 9119 }]
})
)

assert.equal(blocked.kind, 'blocked')

if (blocked.kind !== 'blocked') {
return
}

assert.deepEqual(
blocked.result.processes.map((p) => p.pid),
[79]
)
})

it('classifies Python http.server blockers as safe local previews with a human label', () => {
const o = parseVenvBlockerScanOutput(
ok({
Expand Down
60 changes: 60 additions & 0 deletions hermes_cli/_scan_venv_blockers.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,52 @@ def _is_pausable_gateway(cmdline: str) -> bool:
return looks_like_gateway_command_line(cmdline)


def _ledgered_manual_serve_entries(matches: list[tuple[int, str, str]]) -> list[dict]:
"""Ledger entries for venv holders the updater's serve rung owns.

Same dead-end shape as the gateway exemption above, for MANUAL
``serve``/``dashboard`` backends: the CLI updater's venv guard stops a
self-ledgered backend whose recorded spawner is not alive and relaunches
it on its recorded endpoint (``_ledger_manual_serve_holders`` rung,
#63206). Reporting those as blockers aborts the Desktop preflight with
``venv-blocked`` before ``hermes-setup`` spawns the updater, so the rung
that exists precisely to handle them never runs (#98336).

Delegates to ``update_cmd._ledger_manual_serve_holders`` — the canonical
positive-identity matcher (spawn-ledger ``(pid, create_time)`` for THIS
install, spawner provably not alive) — so the preflight exemption, the
updater's stop rung, and the relauncher share one parser. A
Desktop-owned backend (live spawner) and an unledgered ``serve`` never
appear here and keep blocking. An import failure counts as no manual
serves, which is exactly the pre-exemption behavior.
"""
try:
from hermes_cli.update_cmd import _ledger_manual_serve_holders # noqa: PLC0415

return list(_ledger_manual_serve_holders(matches))
except Exception:
return []


def _manual_serve_evidence(entries: list[dict]) -> list[dict]:
"""Sanitized decision evidence for exempted manual serves.

Structured ledger fields only — pid, purpose, recorded port — never the
command line, which can carry tokens or private endpoints. Lets the
scan result explain *why* a holder disappeared from ``processes``
without echoing argv.
"""
evidence = []
for entry in entries:
pid = entry.get("pid")
if not isinstance(pid, int):
continue
evidence.append(
{"pid": pid, "purpose": entry.get("purpose"), "port": entry.get("port")}
)
return evidence


def main() -> None:
"""Entry point. Prints one JSON doc to stdout. Exits 0 for valid scan."""
try:
Expand All @@ -248,10 +294,18 @@ def main() -> None:
except Exception as exc:
_emit_probe_fail(f"scan aborted: {exc}")

manual_serve_entries = _ledgered_manual_serve_entries(matches)
manual_serve_pids = {
entry["pid"]
for entry in manual_serve_entries
if isinstance(entry.get("pid"), int)
}
processes = []
for pid, name, cmdline in matches:
if _is_pausable_gateway(cmdline):
continue
if pid in manual_serve_pids:
continue
process = {
"pid": pid,
"name": name,
Expand All @@ -271,6 +325,12 @@ def main() -> None:
# Diagnostic only: gateway processes present but not counted as
# blockers because the downstream updater pauses them itself.
"pausable_gateways": exempted,
# Diagnostic only: manual serve/dashboard backends the downstream
# updater stops and relaunches on their recorded endpoints.
"ledgered_manual_serves": len(manual_serve_pids),
# Diagnostic only: sanitized evidence (structured ledger identity,
# never argv) explaining which holders the exemption consumed.
"exempted_manual_serves": _manual_serve_evidence(manual_serve_entries),
}
print(json.dumps(data))
sys.exit(0)
Expand Down
93 changes: 93 additions & 0 deletions tests/hermes_cli/test_scan_venv_blockers.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,3 +368,96 @@ def test_main_gateway_with_long_managed_runtime_path_is_exempt(monkeypatch, caps
assert data["blocked"] is True
assert [p["pid"] for p in data["processes"]] == [92]
assert len(data["processes"][0]["cmdline"]) <= 120


# ---------------------------------------------------------------------------
# manual serve/dashboard exemption — the ledger rung mirror
#
# `hermes update`'s venv guard stops a self-ledgered serve/dashboard backend
# whose recorded spawner is not alive and relaunches it on its recorded
# endpoint (#63206). The Desktop preflight must therefore not report those
# as blockers either, or the handoff dead-ends before the updater runs.
# ---------------------------------------------------------------------------


def _patch_manual_serve_ledger(monkeypatch, entries):
"""Point the canonical matcher at *entries* (list of ledger dicts)."""
import hermes_cli.update_cmd as update_cmd_module

monkeypatch.setattr(update_cmd_module, "_ledger_manual_serve_holders", lambda matches: entries)


def test_main_ledgered_manual_serve_is_exempt(monkeypatch, capsys):
"""A serve/dashboard backend the updater's ledger rung owns (positive
identity, spawner not alive) must scan clear instead of dead-ending the
Desktop update (#98336)."""
serve = (
78,
"python.exe",
r"C:\x\venv\Scripts\python.exe -m hermes_cli.main serve --host 10.0.0.1 --port 9119",
)
_patch_manual_serve_ledger(monkeypatch, [{"pid": 78, "purpose": "serve", "port": 9119}])

code, data = _run_main_with_detector(monkeypatch, capsys, [serve])
assert code == 0
assert data["ok"] is True
assert data["blocked"] is False
assert data["processes"] == []
assert data["ledgered_manual_serves"] == 1
# Sanitized decision evidence: structured ledger identity only — the
# exemption must explain itself without echoing the command line.
assert data["exempted_manual_serves"] == [
{"pid": 78, "purpose": "serve", "port": 9119}
]


def test_main_ledgered_serve_alongside_unledgered_holder_still_blocks(monkeypatch, capsys):
"""Only the ledgered manual serve is exempt; a serve the ledger does not
vouch for (Desktop-owned, live spawner, foreign install) keeps blocking
and is the only reported PID."""
ledgered = (
78,
"python.exe",
r"C:\x\venv\Scripts\python.exe -m hermes_cli.main serve --host 10.0.0.1 --port 9119",
)
desktop_owned = (
79,
"python.exe",
r"C:\x\venv\Scripts\python.exe -m hermes_cli.main serve --host 127.0.0.1 --port 0",
)
_patch_manual_serve_ledger(monkeypatch, [{"pid": 78, "purpose": "serve", "port": 9119}])

code, data = _run_main_with_detector(monkeypatch, capsys, [ledgered, desktop_owned])
assert code == 0
assert data["blocked"] is True
assert [p["pid"] for p in data["processes"]] == [79]
assert data["ledgered_manual_serves"] == 1
# Only the ledger-vouched holder shows up in the sanitized evidence.
assert data["exempted_manual_serves"] == [
{"pid": 78, "purpose": "serve", "port": 9119}
]


def test_main_ledger_matcher_failure_keeps_serve_blocking(monkeypatch, capsys):
"""Fail-closed: when the canonical matcher cannot run (import/ledger
error), the preflight reports the serve as a blocker — the pre-exemption
behavior — instead of guessing."""

def _boom(_matches):
raise RuntimeError("ledger unreadable")

import hermes_cli.update_cmd as update_cmd_module

monkeypatch.setattr(update_cmd_module, "_ledger_manual_serve_holders", _boom)
serve = (
78,
"python.exe",
r"C:\x\venv\Scripts\python.exe -m hermes_cli.main serve --host 10.0.0.1 --port 9119",
)

code, data = _run_main_with_detector(monkeypatch, capsys, [serve])
assert code == 0
assert data["blocked"] is True
assert [p["pid"] for p in data["processes"]] == [78]
assert data["ledgered_manual_serves"] == 0
assert data["exempted_manual_serves"] == []
Loading