mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix(desktop): surface external venv update blockers
This commit is contained in:
parent
01cb38e8ec
commit
d210500b54
5 changed files with 794 additions and 0 deletions
|
|
@ -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, {
|
||||
|
|
|
|||
217
apps/desktop/electron/venv-blocker-scan.test.ts
Normal file
217
apps/desktop/electron/venv-blocker-scan.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
214
apps/desktop/electron/venv-blocker-scan.ts
Normal file
214
apps/desktop/electron/venv-blocker-scan.ts
Normal 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.'
|
||||
)
|
||||
}
|
||||
123
hermes_cli/_scan_venv_blockers.py
Normal file
123
hermes_cli/_scan_venv_blockers.py
Normal 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()
|
||||
202
tests/hermes_cli/test_scan_venv_blockers.py
Normal file
202
tests/hermes_cli/test_scan_venv_blockers.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue