fix(desktop): surface external venv update blockers

This commit is contained in:
atakan g 2026-07-12 20:17:13 +03:00 committed by Teknium
parent 01cb38e8ec
commit d210500b54
5 changed files with 794 additions and 0 deletions

View file

@ -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, {

View file

@ -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'])
})
})

View file

@ -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.'
)
}

View file

@ -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 ``"<redacted>"`` 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 "<redacted>"
# Conservative long-flag pass: preserve the flag name, replace the value
# and everything after it with ``<redacted>``. 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] + "<redacted>"
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()

View file

@ -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 <redacted>."""
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 <redacted>"
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=<redacted>."""
raw = "python.exe --api-key=sk-1234567890abcdef serve"
result = _redact_sensitive_cmdline(raw)
assert result == "python.exe --api-key=<redacted>"
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 '<redacted>' 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 == "<redacted>"
def test_redact_session_key() -> None:
"""--session-key <identifier> 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 <redacted>"
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