From d210500b54536c81fad41235b3d6732cbe532a7d Mon Sep 17 00:00:00 2001 From: atakan g Date: Sun, 12 Jul 2026 20:17:13 +0300 Subject: [PATCH] fix(desktop): surface external venv update blockers --- apps/desktop/electron/main.ts | 38 +++ .../electron/venv-blocker-scan.test.ts | 217 ++++++++++++++++++ apps/desktop/electron/venv-blocker-scan.ts | 214 +++++++++++++++++ hermes_cli/_scan_venv_blockers.py | 123 ++++++++++ tests/hermes_cli/test_scan_venv_blockers.py | 202 ++++++++++++++++ 5 files changed, 794 insertions(+) create mode 100644 apps/desktop/electron/venv-blocker-scan.test.ts create mode 100644 apps/desktop/electron/venv-blocker-scan.ts create mode 100644 hermes_cli/_scan_venv_blockers.py create mode 100644 tests/hermes_cli/test_scan_venv_blockers.py diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index dc4efb801a5f..f2d3083e74da 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -190,6 +190,11 @@ import { } from './update-relaunch' import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' import { spawnUpdaterProcess } from './updater-process' +import { + formatBlockerMessage, + formatProbeFailedMessage, + scanVenvBlockers, +} from './venv-blocker-scan' import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' import { computeWindowOptions, @@ -2876,6 +2881,39 @@ async function applyUpdates(opts = {}) { return { ok: false, error: message } } + // Preflight: after releasing our own backends, check for remaining + // Hermes processes running from this venv. The updater normally refuses + // when it detects a holder, but because the updater is spawned detached + // with stdio:ignore, the user never sees that refusal and the update + // silently fails. This preflight detects holders early and gives the + // user an actionable error. Windows-only; the .pyd lock hazard is a + // Windows phenomenon. ALL failures (blocked, missing python, timeout, + // malformed output, missing psutil) abort the handoff — never proceed + // to the detached updater when the venv state is unknown. + if (IS_WINDOWS) { + const scanOutcome = scanVenvBlockers(updateRoot) + + if (scanOutcome.kind === 'blocked') { + const message = formatBlockerMessage(scanOutcome.result) + + rememberLog(`[updates] venv-blocked: ${scanOutcome.result.processes.length} process(es) hold the install`) + emitUpdateProgress({ stage: 'error', message, percent: null }) + startHermes().catch(() => {}) + + return { ok: false, error: 'venv-blocked', message } + } + + if (scanOutcome.kind === 'probe-failure') { + const message = formatProbeFailedMessage() + + rememberLog(`[updates] venv-blocker probe failed: ${scanOutcome.error}`) + emitUpdateProgress({ stage: 'error', message, percent: null }) + startHermes().catch(() => {}) + + return { ok: false, error: 'venv-probe-failed', message } + } + } + // Detached so the updater outlives this process — it needs us GONE before // `hermes update` will run (the venv shim is locked while we live). const child = spawnUpdaterProcess(updater, updaterArgs, { diff --git a/apps/desktop/electron/venv-blocker-scan.test.ts b/apps/desktop/electron/venv-blocker-scan.test.ts new file mode 100644 index 000000000000..3314848d7021 --- /dev/null +++ b/apps/desktop/electron/venv-blocker-scan.test.ts @@ -0,0 +1,217 @@ +'use strict' + +/** + * Tests for apps/desktop/electron/venv-blocker-scan.ts + * + * Run with: npx tsx --test electron/venv-blocker-scan.test.ts + * (from apps/desktop) + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, it } from 'node:test' + +import { + formatBlockerMessage, + formatProbeFailedMessage, + parseVenvBlockerScanOutput, + resolveVenvPython, + scanVenvBlockers, +} from './venv-blocker-scan' + +// --------------------------------------------------------------------------- +// resolveVenvPython +// --------------------------------------------------------------------------- + +describe('resolveVenvPython', () => { + it('returns a real path when a temp venv python file exists', () => { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-vt-')) + + try { + const scriptsDir = process.platform === 'win32' ? 'Scripts' : 'bin' + const pythonName = process.platform === 'win32' ? 'python.exe' : 'python3' + const dir = path.join(sandbox, 'venv', scriptsDir) + fs.mkdirSync(dir, { recursive: true }) + const pyPath = path.join(dir, pythonName) + fs.writeFileSync(pyPath, '', { mode: 0o755 }) + assert.equal(resolveVenvPython(sandbox), pyPath) + } finally { + fs.rmSync(sandbox, { recursive: true, force: true }) + } + }) + + it('returns null for non-existent venv', () => { + assert.equal(resolveVenvPython('/nonexistent'), null) + }) +}) + +// --------------------------------------------------------------------------- +// formatBlockerMessage / formatProbeFailedMessage +// --------------------------------------------------------------------------- + +describe('formatBlockerMessage', () => { + it('includes PID, name, cmdline, remote-client warning, and retry suggestion', () => { + const msg = formatBlockerMessage({ + blocked: true, + processes: [ + { pid: 101, name: 'python.exe', cmdline: 'serve --host 10.0.0.1' }, + ], + }) + + assert.ok(msg.includes('PID 101')) + assert.ok(msg.includes('python.exe')) + assert.ok(msg.includes('serve')) + assert.ok(msg.includes('remote backend')) + assert.ok(msg.includes('retry')) + assert.ok(!msg.includes('force-venv')) + }) +}) + +describe('formatProbeFailedMessage', () => { + it('suggests retry and hermes update', () => { + const msg = formatProbeFailedMessage() + assert.ok(msg.includes('hermes update')) + assert.ok(msg.includes('retry')) + }) +}) + +// --------------------------------------------------------------------------- +// parseVenvBlockerScanOutput — pure function +// --------------------------------------------------------------------------- + +describe('parseVenvBlockerScanOutput', () => { + const ok = (over: any = {}) => JSON.stringify({ ok: true, blocked: false, processes: [], ...over }) + + it('valid clear', () => { + const o = parseVenvBlockerScanOutput(ok()) + assert.equal(o.kind, 'clear') + }) + + it('valid blocked', () => { + const o = parseVenvBlockerScanOutput(ok({ + blocked: true, + processes: [{ pid: 1, name: 'p', cmdline: 'c' }], + })) + + assert.equal(o.kind, 'blocked') + }) + + it('malformed JSON', () => { + assert.equal(parseVenvBlockerScanOutput('not json').kind, 'probe-failure') + }) + + it('ok=false is rejected', () => { + assert.equal( + parseVenvBlockerScanOutput(JSON.stringify({ ok: false, blocked: false, processes: [] })).kind, + 'probe-failure', + ) + }) + + it('blocked must be boolean', () => { + assert.equal( + parseVenvBlockerScanOutput(ok({ blocked: 'false' })).kind, + 'probe-failure', + ) + }) + + it('blocked=true with empty processes rejected', () => { + assert.equal( + parseVenvBlockerScanOutput(ok({ blocked: true, processes: [] })).kind, + 'probe-failure', + ) + }) + + it('blocked=false with non-empty processes rejected', () => { + assert.equal( + parseVenvBlockerScanOutput(ok({ processes: [{ pid: 1, name: 'p', cmdline: 'c' }] })).kind, + 'probe-failure', + ) + }) + + it('process pid must be positive integer', () => { + assert.equal( + parseVenvBlockerScanOutput(ok({ blocked: true, processes: [{ pid: 0, name: 'p', cmdline: 'c' }] })).kind, + 'probe-failure', + ) + }) + + it('process name must be non-empty string', () => { + assert.equal( + parseVenvBlockerScanOutput(ok({ blocked: true, processes: [{ pid: 1, name: '', cmdline: 'c' }] })).kind, + 'probe-failure', + ) + }) + + it('process missing cmdline is rejected', () => { + assert.equal( + parseVenvBlockerScanOutput(ok({ blocked: true, processes: [{ pid: 1, name: 'p' }] })).kind, + 'probe-failure', + ) + }) +}) + +// --------------------------------------------------------------------------- +// scanVenvBlockers — subprocess with injection +// --------------------------------------------------------------------------- + +describe('scanVenvBlockers', () => { + const stubVenv = () => '/fake/venv/python.exe' + const okJson = JSON.stringify({ ok: true, blocked: false, processes: [] }) + + const blockedJson = JSON.stringify({ + ok: true, blocked: true, processes: [{ pid: 1, name: 'p', cmdline: 'c' }], + }) + + function execReturn(json: string): any { + return ((...args: any[]) => json) as any + } + + function execThrow(status: number, stderr: string): any { + return ((...args: any[]) => { const e: any = new Error(); e.status = status; e.stderr = Buffer.from(stderr); throw e }) as any + } + + it('clear scan returns clear', () => { + assert.equal(scanVenvBlockers('/r', execReturn(okJson), stubVenv).kind, 'clear') + }) + + it('blocked scan returns blocked', () => { + assert.equal(scanVenvBlockers('/r', execReturn(blockedJson), stubVenv).kind, 'blocked') + }) + + it('non-zero exit is probe-failure', () => { + const o = scanVenvBlockers('/r', execThrow(2, 'ModuleNotFoundError'), stubVenv) + assert.equal(o.kind, 'probe-failure') + }) + + it('missing venv python is probe-failure', () => { + const o = scanVenvBlockers('/r', execReturn(okJson), () => null) + assert.equal(o.kind, 'probe-failure') + }) + + it('malformed subprocess output is probe-failure', () => { + const o = scanVenvBlockers('/r', execReturn('bad json'), stubVenv) + assert.equal(o.kind, 'probe-failure') + }) + + it('calls subprocess with correct args, cwd, timeout, stdio', () => { + const calls: any[] = [] + + const spy = ((cmd: string, args: string[], opts: any) => { + calls.push({ cmd, args, cwd: opts.cwd, timeout: opts.timeout, stdio: opts.stdio }) + + return okJson + }) as any + + scanVenvBlockers('/update/root', spy, stubVenv) + assert.equal(calls.length, 1) + const c = calls[0] + assert.ok(c.cmd.endsWith('python.exe')) + assert.deepEqual(c.args, ['-m', 'hermes_cli._scan_venv_blockers']) + assert.equal(c.cwd, '/update/root') + assert.equal(typeof c.timeout, 'number') + assert.ok(c.timeout > 0) + assert.deepEqual(c.stdio, ['ignore', 'pipe', 'pipe']) + }) +}) \ No newline at end of file diff --git a/apps/desktop/electron/venv-blocker-scan.ts b/apps/desktop/electron/venv-blocker-scan.ts new file mode 100644 index 000000000000..ec019ebab2bf --- /dev/null +++ b/apps/desktop/electron/venv-blocker-scan.ts @@ -0,0 +1,214 @@ +'use strict' + +/** + * venv-blocker-scan.ts + * + * Thin helper that runs the Python venv-blocker scan as a subprocess and + * returns a typed result for the Desktop update preflight. + */ + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface VenvBlockerProcess { + pid: number + name: string + cmdline: string +} + +export interface VenvBlockerScanResult { + blocked: boolean + processes: VenvBlockerProcess[] +} + +export type ScanOutcome = + | { kind: 'clear'; result: VenvBlockerScanResult } + | { kind: 'blocked'; result: VenvBlockerScanResult } + | { kind: 'probe-failure'; error: string } + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SCAN_TIMEOUT_MS = 15000 +const SCAN_MODULE = 'hermes_cli._scan_venv_blockers' + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Strictly validate and parse the JSON output from the venv-blocker scan. + * Pure function — no side effects. + */ +export function parseVenvBlockerScanOutput(raw: string): ScanOutcome { + let parsed: any + + try { + parsed = JSON.parse(raw) + } catch { + return { kind: 'probe-failure', error: 'malformed JSON' } + } + + if (!parsed || typeof parsed !== 'object' || parsed.ok !== true) { + return { kind: 'probe-failure', error: 'missing or invalid ok field' } + } + + if (typeof parsed.blocked !== 'boolean') { + return { kind: 'probe-failure', error: 'blocked must be a boolean' } + } + + if (!Array.isArray(parsed.processes)) { + return { kind: 'probe-failure', error: 'processes must be an array' } + } + + const processes: VenvBlockerProcess[] = [] + + for (const entry of parsed.processes) { + if (!entry || typeof entry !== 'object') { + return { kind: 'probe-failure', error: 'process entry must be an object' } + } + + const { pid, name, cmdline } = entry + + if (!Number.isInteger(pid) || pid <= 0) { + return { kind: 'probe-failure', error: 'process pid must be a positive integer' } + } + + if (typeof name !== 'string' || name.length === 0) { + return { kind: 'probe-failure', error: 'process name must be a non-empty string' } + } + + if (typeof cmdline !== 'string') { + return { kind: 'probe-failure', error: 'process cmdline must be a string' } + } + + processes.push({ pid, name, cmdline }) + } + + // Reject inconsistent combinations + if (parsed.blocked && processes.length === 0) { + return { kind: 'probe-failure', error: 'blocked is true but process list is empty' } + } + + if (!parsed.blocked && processes.length > 0) { + return { kind: 'probe-failure', error: 'blocked is false but process list is non-empty' } + } + + return parsed.blocked + ? { kind: 'blocked', result: { blocked: true, processes } } + : { kind: 'clear', result: { blocked: false, processes } } +} + +/** + * Run the venv-blocker scan subprocess. Accepts optional overrides for + * testing (dependency injection). + */ +export function scanVenvBlockers( + updateRoot: string, + execOverride?: typeof execFileSync, + resolveOverride?: typeof resolveVenvPython, +): ScanOutcome { + const execFn = execOverride || execFileSync + const resolveFn = resolveOverride || resolveVenvPython + const venvPython = resolveFn(updateRoot) + + if (!venvPython) { + return { kind: 'probe-failure', error: 'venv python not found' } + } + + let stdout: string + + try { + const proc = execFn( + venvPython, + ['-m', SCAN_MODULE], + { + cwd: updateRoot, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: SCAN_TIMEOUT_MS, + windowsHide: true, + } as any, + ) + + stdout = (proc as unknown as string) + } catch (err: any) { + const diag = [`exit code ${err.status ?? -1}`] + + if (err.stderr) {diag.push(String(err.stderr).slice(0, 200))} + + return { kind: 'probe-failure', error: diag.join('; ') } + } + + return parseVenvBlockerScanOutput(stdout) +} + +// --------------------------------------------------------------------------- +// Internal helpers (exported for testing) +// --------------------------------------------------------------------------- + +/** Resolve the venv python path. Returns null if the file does not exist. */ +export function resolveVenvPython(updateRoot: string): string | null { + const isWindows = process.platform === 'win32' + const pythonName = isWindows ? 'python.exe' : 'python3' + const scriptsDir = isWindows ? 'Scripts' : 'bin' + const candidate = path.join(updateRoot, 'venv', scriptsDir, pythonName) + + try { + fs.accessSync(candidate) + + return candidate + } catch { + return null + } +} + +/** + * Build a human-readable error message from blocker scan results. + * Does NOT recommend --force-venv. + */ +export function formatBlockerMessage(result: VenvBlockerScanResult): string { + const lines = [ + 'Update aborted: another Hermes process is using this installation.', + '', + 'These processes must be stopped before updating:', + '', + ] + + for (const proc of result.processes.slice(0, 10)) { + lines.push(` PID ${proc.pid} ${proc.name} ${proc.cmdline}`) + } + + if (result.processes.length > 10) { + lines.push(` ... and ${result.processes.length - 10} more`) + } + + lines.push('') + lines.push( + 'Close the terminal, app, or service owning that process. If it is a ' + + 'remote backend, stopping it will disconnect remote clients.', + ) + lines.push( + 'Then retry the update.', + ) + + return lines.join('\n') +} + +/** + * Build a probe-failure error message. + */ +export function formatProbeFailedMessage(): string { + return ( + 'Update aborted: Desktop could not verify the Hermes installation is free.\n' + + '\n' + + 'Close other Hermes windows and terminals, then retry. If the problem\n' + + 'persists, run `hermes update` in a terminal for detailed diagnostics.' + ) +} \ No newline at end of file diff --git a/hermes_cli/_scan_venv_blockers.py b/hermes_cli/_scan_venv_blockers.py new file mode 100644 index 000000000000..0842577ba61d --- /dev/null +++ b/hermes_cli/_scan_venv_blockers.py @@ -0,0 +1,123 @@ +"""``hermes_cli/_scan_venv_blockers.py`` — Standalone venv-process scan for JSON consumption. + +Invoked by the Desktop Electron app:: + + venv\\Scripts\\python.exe -m hermes_cli._scan_venv_blockers + +Exits 0 for valid clear or blocked results. Non-zero exit signals probe +failure (the detector itself crashed, psutil unavailable, etc.). Exactly +one JSON document on stdout; diagnostics on stderr only. +""" + +from __future__ import annotations + +import json +import sys +from typing import NoReturn + +# Long CLI flags whose argument value must be redacted from the cmdline. +_SENSITIVE_LONG_FLAGS: list[str] = [ + "--token", + "--api-key", + "--password", + "--secret", + "--authorization", + "--access-key", + "--private-key", + "--session-key", +] + + +def _probe_fail_json() -> str: + """Return the standard probe-failure JSON document.""" + return json.dumps({"ok": False, "blocked": False, "processes": []}) + + +def _emit_probe_fail(diagnostic: str) -> NoReturn: + """Print one JSON to stdout, diagnostic to stderr, exit non-zero.""" + print(_probe_fail_json()) + print(diagnostic, file=sys.stderr) + sys.exit(1) + + +def _find_flag(text: str, flag: str) -> int: + """Return the index of *flag* when it starts the string or follows a space. + + Returns -1 when not found. This avoids matching ``--token`` inside an + embedded token or path like ``/some--token-thing``. + """ + low = text.lower() + fl = flag.lower() + pos = 0 + while True: + idx = low.find(fl, pos) + if idx == -1: + return -1 + if idx == 0 or text[idx - 1] == " ": + return idx + pos = idx + 1 + + +def _redact_sensitive_cmdline(cmdline: str) -> str: + """Apply generic secret redaction then long-flag redaction. + + If the generic redactor itself fails, return ``""`` — the PID + and process name still provide actionable diagnostics. + """ + # Generic pass: the project's shared secret redactor. + try: + from agent.redact import redact_sensitive_text # noqa: PLC0415 + + cmdline = redact_sensitive_text(cmdline, force=True) + except Exception: + return "" + + # Conservative long-flag pass: preserve the flag name, replace the value + # and everything after it with ````. Short flags (-t, -k, -p) + # are intentionally not redacted — they are ambiguous and may be useful + # diagnostics (toolset, port, profile). + earliest = len(cmdline) + for flag in _SENSITIVE_LONG_FLAGS: + # --flag=value → preserve "--flag=" + idx = _find_flag(cmdline, flag + "=") + if idx != -1 and idx + len(flag) + 1 < earliest: + earliest = idx + len(flag) + 1 + # --flag value → preserve "--flag " + idx = _find_flag(cmdline, flag + " ") + if idx != -1 and idx + len(flag) + 1 < earliest: + earliest = idx + len(flag) + 1 + + if earliest < len(cmdline): + return cmdline[:earliest] + "" + return cmdline + + +def main() -> None: + """Entry point. Prints one JSON doc to stdout. Exits 0 for valid scan.""" + try: + import psutil # noqa: PLC0415, F401 + except Exception as exc: + _emit_probe_fail(f"psutil is not available: {exc}") + + try: + from hermes_cli.main import _detect_venv_python_processes # noqa: PLC0415 + + matches = _detect_venv_python_processes() + except Exception as exc: + _emit_probe_fail(f"scan aborted: {exc}") + + processes = [ + { + "pid": pid, + "name": name, + "cmdline": _redact_sensitive_cmdline(cmdline), + } + for pid, name, cmdline in matches + ] + data = {"ok": True, "blocked": bool(processes), "processes": processes} + print(json.dumps(data)) + sys.exit(0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/hermes_cli/test_scan_venv_blockers.py b/tests/hermes_cli/test_scan_venv_blockers.py new file mode 100644 index 000000000000..b6146033b816 --- /dev/null +++ b/tests/hermes_cli/test_scan_venv_blockers.py @@ -0,0 +1,202 @@ +"""Tests for hermes_cli/_scan_venv_blockers.py. + +Tests call the real production functions (``main``, ``_redact_sensitive_cmdline``). +The detector is patched directly so no real process table interaction occurs. +""" + +from __future__ import annotations + +import builtins +import json +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +import agent.redact as redact_module +from hermes_cli._scan_venv_blockers import ( + _redact_sensitive_cmdline, + main, +) + + +# --------------------------------------------------------------------------- +# main() — stdout, stderr, exit code (with patched detector) +# --------------------------------------------------------------------------- + + +def _psutil_fake() -> dict: + """Return a sys.modules dict entry that makes psutil appear available.""" + return {"psutil": types.SimpleNamespace(Process=lambda *a: MagicMock())} + + +def test_main_no_holders_prints_clear_json(tmp_path: Path, capsys) -> None: + from hermes_cli import main as cli_main + + fake_detect = MagicMock(return_value=[]) + with patch.object(cli_main, "_is_windows", return_value=True), patch.object( + cli_main, "PROJECT_ROOT", tmp_path + ), patch.object(cli_main, "_detect_venv_python_processes", fake_detect), patch.dict( + sys.modules, _psutil_fake() + ): + with pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code == 0 + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == {"ok": True, "blocked": False, "processes": []} + + +def test_main_holders_prints_blocked_json(tmp_path: Path, capsys) -> None: + from hermes_cli import main as cli_main + + fake_detect = MagicMock( + return_value=[(101, "python.exe", "python.exe -m hermes_cli.main serve --host 10.0.0.1")] + ) + with patch.object(cli_main, "_is_windows", return_value=True), patch.object( + cli_main, "PROJECT_ROOT", tmp_path + ), patch.object(cli_main, "_detect_venv_python_processes", fake_detect), patch.dict( + sys.modules, _psutil_fake() + ): + with pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code == 0 + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["ok"] is True + assert data["blocked"] is True + assert len(data["processes"]) == 1 + p = data["processes"][0] + assert p["pid"] == 101 + assert p["name"] == "python.exe" + assert "serve" in p["cmdline"] + + +def test_main_detector_exception_exits_nonzero(tmp_path: Path, capsys) -> None: + from hermes_cli import main as cli_main + + with patch.object( + cli_main, "_detect_venv_python_processes", side_effect=RuntimeError("boom") + ), patch.object(cli_main, "_is_windows", return_value=True), patch.object( + cli_main, "PROJECT_ROOT", tmp_path + ), patch.dict(sys.modules, _psutil_fake()): + with pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code != 0 + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == {"ok": False, "blocked": False, "processes": []} + assert "boom" in captured.err + + +def test_main_psutil_unavailable_exits_nonzero(tmp_path: Path, capsys) -> None: + from hermes_cli import main as cli_main + + with patch.object(cli_main, "_is_windows", return_value=True), patch.object( + cli_main, "PROJECT_ROOT", tmp_path + ), patch.dict(sys.modules, {"psutil": None}): + with pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code != 0 + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == {"ok": False, "blocked": False, "processes": []} + + +def test_main_import_hermes_cli_main_fails(tmp_path: Path, capsys) -> None: + """When the import of hermes_cli.main raises, main() must produce one + parseable ok=false JSON on stdout, the diagnostic on stderr, and exit + non-zero.""" + from hermes_cli import main as cli_main + + real_import = builtins.__import__ + + def selective_import(name, *args, **kwargs): + if name == "hermes_cli.main": + raise ImportError("detector import failed") + return real_import(name, *args, **kwargs) + + with patch.object(cli_main, "_is_windows", return_value=True), patch.object( + cli_main, "PROJECT_ROOT", tmp_path + ), patch.dict(sys.modules, _psutil_fake()), patch.object( + builtins, "__import__", selective_import + ): + with pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code != 0 + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == {"ok": False, "blocked": False, "processes": []} + assert "detector import failed" in captured.err + + +# --------------------------------------------------------------------------- +# _redact_sensitive_cmdline +# --------------------------------------------------------------------------- + + +def test_redact_long_flag_value_space_separated() -> None: + """--token SECRET must preserve --token and emit --token .""" + raw = "python.exe -m hermes_cli.main serve --token ghp_abc123 --host 10.0.0.1" + result = _redact_sensitive_cmdline(raw) + assert result == "python.exe -m hermes_cli.main serve --token " + assert "ghp_abc123" not in result + + +def test_redact_long_flag_equals_form() -> None: + """--api-key=SECRET must preserve --api-key= and emit --api-key=.""" + raw = "python.exe --api-key=sk-1234567890abcdef serve" + result = _redact_sensitive_cmdline(raw) + assert result == "python.exe --api-key=" + assert "sk-1234567890abcdef" not in result + + +def test_redact_sensitive_text_failure_returns_fully_redacted() -> None: + """When agent.redact.redact_sensitive_text raises, the entire result + must equal '' so PID and name still provide diagnostics.""" + with patch.object( + redact_module, + "redact_sensitive_text", + side_effect=RuntimeError("no redactor"), + ): + result = _redact_sensitive_cmdline("python.exe --token abc123") + + assert result == "" + + +def test_redact_session_key() -> None: + """--session-key must redact the value and everything after.""" + raw = "python.exe -m tui_gateway.slash_worker --session-key 20260712-abcdef --model test" + result = _redact_sensitive_cmdline(raw) + assert result == "python.exe -m tui_gateway.slash_worker --session-key " + + +def test_redact_normal_host_port_profile_remain() -> None: + raw = "python.exe -m hermes_cli.main serve --host 10.0.0.1 --port 9119 --profile work" + result = _redact_sensitive_cmdline(raw) + assert "10.0.0.1" in result + assert "9119" in result + assert "work" in result + + +def test_redact_no_sensitive_flags_is_noop() -> None: + raw = "python.exe -m hermes_cli.main serve --host 127.0.0.1" + assert _redact_sensitive_cmdline(raw) == raw + + +def test_redact_empty_string() -> None: + assert _redact_sensitive_cmdline("") == "" + + +def test_redact_short_flags_not_redacted() -> None: + """Short flags -t (toolset), -p (profile), -k are NOT redacted.""" + raw = "python.exe -m hermes_cli.main serve -t web -p default -k somearg" + result = _redact_sensitive_cmdline(raw) + assert result == raw # short flags pass through unchanged \ No newline at end of file