Compare commits

..
Author SHA1 Message Date
Brooklyn Nicholson 237807ad3a Include git SHA in /version output via banner label helper.
Reuses format_banner_version_label() so CLI, TUI, gateway, and desktop show upstream/local commit when available.
2026-06-05 19:39:58 -05:00
Brooklyn Nicholson d95c76aa37 Add /version slash command across CLI, gateway, TUI, and desktop.
Surfaces Hermes Agent version info on demand without leaving chat; works mid-run like /help and /update.
2026-06-05 19:38:32 -05:00
16 changed files with 84 additions and 1041 deletions
@@ -3,32 +3,13 @@ import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesConnection } from '@/global'
import type * as HermesModule from '@/hermes'
import { startRemoteUpdate } from '@/store/remote-update'
import { $sessions, setConnection, setSessions } from '@/store/session'
import { openUpdatesWindow } from '@/store/updates'
import { $sessions, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { usePromptActions } from './use-prompt-actions'
// Spread the real module (its functions are import-safe; side effects only run
// when called) and override only the network-touching transcribeAudio. A
// hand-listed partial mock breaks as soon as another store imported into this
// graph (e.g. profile.ts → setApiRequestProfile at module init) reaches for a
// new @/hermes export.
vi.mock('@/hermes', async importOriginal => {
const actual = await importOriginal<typeof HermesModule>()
return { ...actual, transcribeAudio: vi.fn() }
})
vi.mock('@/store/updates', () => ({
openUpdatesWindow: vi.fn()
}))
vi.mock('@/store/remote-update', () => ({
startRemoteUpdate: vi.fn()
vi.mock('@/hermes', () => ({
transcribeAudio: vi.fn()
}))
// The active id the desktop holds is the *runtime* session id from
@@ -109,7 +90,6 @@ describe('usePromptActions /title', () => {
it('renames via the session.title RPC (with the runtime id), updates the sidebar store, and refreshes', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) =>
(method === 'session.title' ? { pending: false, title: 'New title' } : {}) as never
)
@@ -133,7 +113,6 @@ describe('usePromptActions /title', () => {
it('reports the queued state when the session row is not persisted yet', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) =>
(method === 'session.title' ? { pending: true, title: 'Fresh chat' } : {}) as never
)
@@ -167,7 +146,6 @@ describe('usePromptActions /title', () => {
it('surfaces a rename error without touching the sidebar store', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.title') {
throw new Error('Title too long')
@@ -186,48 +164,3 @@ describe('usePromptActions /title', () => {
expect($sessions.get()[0]?.title).toBe('Old title')
})
})
describe('usePromptActions /update', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
setConnection(() => null)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.mocked(openUpdatesWindow).mockClear()
vi.mocked(startRemoteUpdate).mockClear()
setConnection(() => null)
})
it('opens the native updater overlay for a local backend (no slash worker)', async () => {
setConnection(() => ({ mode: 'local' }) as HermesConnection)
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await handle!.submitText('/update')
expect(openUpdatesWindow).toHaveBeenCalledTimes(1)
expect(startRemoteUpdate).not.toHaveBeenCalled()
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
it('triggers a remote self-update (not the local overlay) for a remote backend', async () => {
setConnection(() => ({ mode: 'remote' }) as HermesConnection)
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await handle!.submitText('/update')
expect(startRemoteUpdate).toHaveBeenCalledTimes(1)
expect(openUpdatesWindow).not.toHaveBeenCalled()
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
})
@@ -31,10 +31,8 @@ import {
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { startRemoteUpdate } from '@/store/remote-update'
import {
$busy,
$connection,
$messages,
$yoloActive,
setAwaitingResponse,
@@ -43,7 +41,6 @@ import {
setSessions,
setYoloActive
} from '@/store/session'
import { openUpdatesWindow } from '@/store/updates'
import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types'
@@ -447,24 +444,6 @@ export function usePromptActions({
return
}
// /update has two rails. Local backend: the desktop's native (Electron)
// updater overlay, which patches the local checkout. Remote backend: the
// gateway can't be reached by the local updater, so tell it to update
// itself (update.start) and track it with a status pill that survives the
// restart/reconnect. Either way we avoid the CLI's interactive modal,
// which can't run headless in the slash worker.
if (normalizedName === 'update') {
if ($connection.get()?.mode === 'remote') {
void startRemoteUpdate(requestGateway)
return
}
openUpdatesWindow()
return
}
// /profile selects which profile new chats open in — no app relaunch.
// A profile is per-session now, so an existing thread can't change its
// profile mid-stream; `/profile <name>` instead points the next new chat
@@ -552,7 +531,6 @@ export function usePromptActions({
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true
@@ -15,6 +15,7 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashSuggestion('/branch')).toBe(true)
expect(isDesktopSlashSuggestion('/skin')).toBe(true)
expect(isDesktopSlashSuggestion('/usage')).toBe(true)
expect(isDesktopSlashSuggestion('/version')).toBe(true)
expect(isDesktopSlashSuggestion('/yolo')).toBe(true)
expect(isDesktopSlashCommand('/yolo')).toBe(true)
})
@@ -25,11 +26,6 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashCommand('/my-skill')).toBe(true)
})
it('surfaces /update so the desktop native updater is discoverable', () => {
expect(isDesktopSlashSuggestion('/update')).toBe(true)
expect(isDesktopSlashCommand('/update')).toBe(true)
})
it('hides terminal, messaging, and dedicated-UI commands from suggestions', () => {
expect(isDesktopSlashSuggestion('/clear')).toBe(false)
expect(isDesktopSlashSuggestion('/compact')).toBe(false)
@@ -42,8 +42,8 @@ const DESKTOP_COMMAND_META = [
['/stop', 'Stop running background processes'],
['/title', 'Rename the current session'],
['/undo', 'Remove the last user/assistant exchange'],
['/update', 'Update Hermes to the latest version'],
['/usage', 'Show token usage for this session'],
['/version', 'Show Hermes Agent version'],
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
] as const
@@ -99,6 +99,7 @@ const TERMINAL_ONLY_COMMANDS = new Set([
'/statusbar',
'/toolsets',
'/tools',
'/update',
'/verbose'
])
@@ -1,147 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $remoteUpdate, resetRemoteUpdate, startRemoteUpdate } from './remote-update'
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
dismissNotification: vi.fn()
}))
const POLL_STEP = 2_100
describe('startRemoteUpdate', () => {
beforeEach(() => {
vi.useFakeTimers()
resetRemoteUpdate()
})
afterEach(() => {
vi.useRealTimers()
})
it('runs starting → running → restarting → done on a clean update + restart', async () => {
const requestGateway = vi
.fn()
.mockResolvedValueOnce({ started: true }) // update.start
.mockResolvedValueOnce({ running: true, finished: false, exit_code: null, output: '' }) // update.status
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: 'done' }) // update.status
.mockResolvedValueOnce({ restarting: true }) // gateway.restart
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: 'done' }) // update.status (reconnected)
const promise = startRemoteUpdate(requestGateway)
await vi.advanceTimersByTimeAsync(POLL_STEP) // → running
await vi.advanceTimersByTimeAsync(POLL_STEP) // → finished → gateway.restart → wait
await vi.advanceTimersByTimeAsync(POLL_STEP) // → reconnect poll → done
await promise
expect($remoteUpdate.get().phase).toBe('done')
expect($remoteUpdate.get().message).toContain('restarted')
expect(requestGateway).toHaveBeenNthCalledWith(1, 'update.start')
expect(requestGateway).toHaveBeenCalledWith('gateway.restart')
})
it('surfaces an error (with output tail) on a non-zero exit', async () => {
const requestGateway = vi
.fn()
.mockResolvedValueOnce({ started: true })
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 1, output: 'pulling…\nfatal: boom' })
const promise = startRemoteUpdate(requestGateway)
await vi.advanceTimersByTimeAsync(POLL_STEP)
await promise
expect($remoteUpdate.get().phase).toBe('error')
expect($remoteUpdate.get().message).toContain('boom')
expect(requestGateway).not.toHaveBeenCalledWith('gateway.restart')
})
it('shows reconnecting while the backend is down mid-update, then restarts', async () => {
const requestGateway = vi
.fn()
.mockResolvedValueOnce({ started: true })
.mockRejectedValueOnce(new Error('connection closed'))
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' })
.mockResolvedValueOnce({ restarting: true })
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' })
const promise = startRemoteUpdate(requestGateway)
await vi.advanceTimersByTimeAsync(POLL_STEP)
expect($remoteUpdate.get().phase).toBe('reconnecting')
await vi.advanceTimersByTimeAsync(POLL_STEP) // → finished → restart → wait
await vi.advanceTimersByTimeAsync(POLL_STEP) // → reconnect poll → done
await promise
expect($remoteUpdate.get().phase).toBe('done')
})
it('treats a dropped transport on gateway.restart as the restart succeeding', async () => {
const requestGateway = vi
.fn()
.mockResolvedValueOnce({ started: true })
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' })
.mockRejectedValueOnce(new Error('Hermes gateway connection closed')) // gateway.restart drops
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' }) // reconnected
const promise = startRemoteUpdate(requestGateway)
await vi.advanceTimersByTimeAsync(POLL_STEP) // → finished → gateway.restart (drops) → wait
await vi.advanceTimersByTimeAsync(POLL_STEP) // → reconnect poll → done
await promise
expect($remoteUpdate.get().phase).toBe('done')
expect($remoteUpdate.get().message).toContain('restarted')
})
it('falls back to a manual-restart hint when the backend lacks gateway.restart', async () => {
const requestGateway = vi
.fn()
.mockResolvedValueOnce({ started: true })
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' })
.mockRejectedValueOnce(new Error('method not found')) // gateway.restart unsupported (RPC error)
const promise = startRemoteUpdate(requestGateway)
await vi.advanceTimersByTimeAsync(POLL_STEP)
await promise
expect($remoteUpdate.get().phase).toBe('done')
expect($remoteUpdate.get().message).toContain('Restart it')
})
it('errors without polling when the update fails to start', async () => {
const requestGateway = vi.fn().mockRejectedValueOnce(new Error('not a git checkout'))
await startRemoteUpdate(requestGateway)
expect($remoteUpdate.get().phase).toBe('error')
expect(requestGateway).toHaveBeenCalledTimes(1)
})
it('ignores a second start while one is already in flight', async () => {
const requestGateway = vi
.fn()
.mockResolvedValueOnce({ started: true })
.mockResolvedValueOnce({ running: true, finished: false, exit_code: null, output: '' })
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' })
.mockResolvedValueOnce({ restarting: true })
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' })
const first = startRemoteUpdate(requestGateway)
await vi.advanceTimersByTimeAsync(0)
await startRemoteUpdate(requestGateway) // ignored — a run is already active
await vi.advanceTimersByTimeAsync(POLL_STEP)
await vi.advanceTimersByTimeAsync(POLL_STEP)
await vi.advanceTimersByTimeAsync(POLL_STEP)
await first
const startCalls = requestGateway.mock.calls.filter(call => call[0] === 'update.start')
expect(startCalls).toHaveLength(1)
})
})
-191
View File
@@ -1,191 +0,0 @@
/**
* Remote backend self-update. When a desktop window drives a backend on
* another host, the Electron native updater can't reach it — only the remote
* gateway can update its own box. This kicks off `update.start` on the gateway,
* polls `update.status` until the checkout is rewritten, then asks the gateway
* to re-exec itself (`gateway.restart`) so the new code actually loads — riding
* out the disconnect and surfacing a lightweight status pill (a persistent
* notification) throughout.
*/
import { atom } from 'nanostores'
import { dismissNotification, notify } from '@/store/notifications'
export type RemoteUpdatePhase =
| 'idle'
| 'starting'
| 'running'
| 'restarting'
| 'reconnecting'
| 'done'
| 'error'
export interface RemoteUpdateState {
phase: RemoteUpdatePhase
message: string
}
interface RemoteUpdateStatus {
running: boolean
finished: boolean
exit_code: number | null
output: string
}
type RequestGateway = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
const TOAST_ID = 'remote-backend-update'
const POLL_INTERVAL_MS = 2_000
const POLL_TIMEOUT_MS = 30 * 60 * 1_000
// The backend drops while it re-execs; give it generous room to come back
// before we stop driving the pill (the gateway keeps reconnecting regardless).
const RESTART_TIMEOUT_MS = 3 * 60 * 1_000
const IDLE: RemoteUpdateState = { phase: 'idle', message: '' }
const ACTIVE_PHASES: ReadonlySet<RemoteUpdatePhase> = new Set([
'starting',
'running',
'restarting',
'reconnecting'
])
export const $remoteUpdate = atom<RemoteUpdateState>(IDLE)
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
function setPhase(phase: RemoteUpdatePhase, message: string): void {
$remoteUpdate.set({ phase, message })
if (phase === 'error') {
notify({ id: TOAST_ID, kind: 'error', title: 'Backend update', message, durationMs: 0 })
} else if (phase === 'done') {
notify({ id: TOAST_ID, kind: 'success', title: 'Backend update', message })
} else if (ACTIVE_PHASES.has(phase)) {
notify({ id: TOAST_ID, kind: 'info', title: 'Backend update', message, durationMs: 0 })
} else {
dismissNotification(TOAST_ID)
}
}
export function resetRemoteUpdate(): void {
dismissNotification(TOAST_ID)
$remoteUpdate.set(IDLE)
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
// A dropped transport (the backend re-execing) vs. an RPC-level error (e.g. an
// older backend that doesn't know `gateway.restart`). Only the former means a
// restart is actually underway.
function isConnectionError(error: unknown): boolean {
return /not connected|connection closed|could not connect|timed out|timeout/i.test(errorText(error))
}
function tail(output: string, lines = 4): string {
const trimmed = (output || '').trim()
return trimmed ? trimmed.split('\n').slice(-lines).join('\n') : ''
}
export async function startRemoteUpdate(requestGateway: RequestGateway): Promise<void> {
if (ACTIVE_PHASES.has($remoteUpdate.get().phase)) {
return
}
setPhase('starting', 'Starting backend update…')
try {
await requestGateway('update.start')
} catch (error) {
setPhase('error', errorText(error) || 'Could not start the backend update.')
return
}
setPhase('running', 'Updating remote backend…')
await pollRemoteUpdate(requestGateway)
}
async function pollRemoteUpdate(requestGateway: RequestGateway): Promise<void> {
const deadline = Date.now() + POLL_TIMEOUT_MS
while (Date.now() < deadline) {
await delay(POLL_INTERVAL_MS)
let status: RemoteUpdateStatus
try {
status = await requestGateway<RemoteUpdateStatus>('update.status')
} catch {
// The backend likely dropped to restart with the new code. requestGateway
// already attempted a reconnect; reflect that and keep polling.
setPhase('reconnecting', 'Reconnecting to backend…')
continue
}
if ($remoteUpdate.get().phase === 'reconnecting') {
setPhase('running', 'Updating remote backend…')
}
if (status.finished) {
if ((status.exit_code ?? 1) === 0) {
await restartRemoteBackend(requestGateway)
} else {
setPhase('error', tail(status.output) || 'Backend update failed.')
}
return
}
}
setPhase('error', 'Backend update timed out.')
}
// The checkout is updated but the process is still running old code. Ask the
// gateway to re-exec itself, then ride out the disconnect until it answers
// again. Best-effort: a backend that can't restart (managed install, or an
// older build without the RPC) just tells the user to restart it by hand.
async function restartRemoteBackend(requestGateway: RequestGateway): Promise<void> {
setPhase('restarting', 'Restarting backend to load the update…')
let restarting = true
try {
await requestGateway('gateway.restart')
} catch (error) {
// A dropped transport is the success signal — the backend re-execed before
// (or while) replying. Any other error means the restart never happened.
restarting = isConnectionError(error)
if (!restarting) {
setPhase('done', 'Backend updated. Restart it to load the new version.')
return
}
}
await waitForReconnect(requestGateway)
}
async function waitForReconnect(requestGateway: RequestGateway): Promise<void> {
const deadline = Date.now() + RESTART_TIMEOUT_MS
while (Date.now() < deadline) {
await delay(POLL_INTERVAL_MS)
try {
await requestGateway<RemoteUpdateStatus>('update.status')
setPhase('done', 'Backend updated and restarted.')
return
} catch {
setPhase('reconnecting', 'Reconnecting to backend…')
}
}
setPhase('done', 'Backend updated. Reconnect once the backend is back.')
}
+4
View File
@@ -9015,6 +9015,10 @@ class HermesCLI:
elif canonical == "update":
if self._handle_update_command():
return False
elif canonical == "version":
from hermes_cli.main import _print_version_info
_print_version_info(check_updates=True)
elif canonical == "paste":
self._handle_paste_command()
elif canonical == "image":
+11
View File
@@ -7932,6 +7932,8 @@ class GatewayRunner:
return await self._handle_profile_command(event)
if _cmd_def_inner.name == "update":
return await self._handle_update_command(event)
if _cmd_def_inner.name == "version":
return await self._handle_version_command(event)
# Catch-all: any other recognized slash command reached the
# running-agent guard. Reject gracefully rather than falling
@@ -8288,6 +8290,9 @@ class GatewayRunner:
if canonical == "update":
return await self._handle_update_command(event)
if canonical == "version":
return await self._handle_version_command(event)
if canonical == "debug":
return await self._handle_debug_command(event)
@@ -10913,6 +10918,12 @@ class GatewayRunner:
return event.platform_update_id <= recorded_uid
async def _handle_version_command(self, event: MessageEvent) -> str:
"""Handle /version — show the running Hermes Agent version."""
from hermes_cli.banner import format_banner_version_label
return format_banner_version_label()
async def _handle_help_command(self, event: MessageEvent) -> str:
"""Handle /help command - list available commands."""
from hermes_cli.commands import gateway_help_lines
+2
View File
@@ -216,6 +216,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("image", "Attach a local image file for your next prompt", "Info",
cli_only=True, args_hint="<path>"),
CommandDef("update", "Update Hermes Agent to the latest version", "Info"),
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",)),
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
# Exit
@@ -349,6 +350,7 @@ ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
"steer",
"stop",
"update",
"version",
}
)
+3 -1
View File
@@ -6647,7 +6647,9 @@ def cmd_import(args):
def _print_version_info(*, check_updates: bool = True) -> None:
print(f"Hermes Agent v{__version__} ({__release_date__})")
from hermes_cli.banner import format_banner_version_label
print(format_banner_version_label())
print(f"Project: {PROJECT_ROOT}")
# Show Python version
+28
View File
@@ -0,0 +1,28 @@
"""Tests for the /version slash command."""
from unittest.mock import patch
from cli import HermesCLI
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command
def test_version_command_is_registered():
cmd = resolve_command("version")
assert cmd is not None
assert cmd.name == "version"
assert cmd.category == "Info"
assert resolve_command("v") is cmd
def test_version_is_gateway_known():
assert "version" in GATEWAY_KNOWN_COMMANDS
assert "v" in GATEWAY_KNOWN_COMMANDS
def test_process_command_version_prints_version_info():
cli_obj = HermesCLI.__new__(HermesCLI)
with patch("hermes_cli.main._print_version_info") as mock_print:
assert cli_obj.process_command("/version") is True
mock_print.assert_called_once_with(check_updates=True)
+12
View File
@@ -0,0 +1,12 @@
"""Tests for gateway /version command."""
import asyncio
from hermes_cli.banner import format_banner_version_label
def test_gateway_version_command_returns_release_line():
from gateway.run import GatewayRunner
result = asyncio.run(GatewayRunner._handle_version_command(None, None)) # type: ignore[arg-type]
assert result == format_banner_version_label()
-160
View File
@@ -1,160 +0,0 @@
"""Regression tests for the rg/grep error guard in content search.
The guard in ``_search_with_rg`` / ``_search_with_grep`` had two defects on
``origin/main`` (see PR replacing #39710):
1. **Unreachable on a hard error.** Both methods pipe the search through
``| head`` with no ``pipefail``, so the pipeline reported head's exit code
(0), masking rg/grep's error code (2). The guard never fired, and the
error text — merged into stdout by ``_exec`` (``stderr=subprocess.STDOUT``)
— was parsed as bogus match lines instead of being surfaced.
2. **Would have nuked partial results if it ever did fire.** A broad
``exit_code == 2`` check discards real matches whenever rg/grep also hit a
non-fatal error (e.g. one unreadable file in a tree that otherwise
matched), which both tools signal with exit 2.
The fix adds ``set -o pipefail`` so the real exit code propagates, splits
tool diagnostics from match output by *shape*, and only surfaces an error
when exit==2 AND no usable match payload remains.
These tests drive the real methods through the real local terminal backend.
"""
import os
import shutil
import pytest
from tools.file_operations import (
ShellFileOperations,
_split_tool_diagnostics,
)
from tools.environments.local import LocalEnvironment
def _ops(root):
return ShellFileOperations(LocalEnvironment(cwd=str(root)), cwd=str(root))
@pytest.fixture
def match_tree(tmp_path):
"""A tree with several files all containing 'needle'."""
for i in range(5):
(tmp_path / f"f{i}.txt").write_text(f"needle line {i}\n")
return tmp_path
@pytest.fixture
def partial_error_tree(tmp_path):
"""A tree with matches plus one unreadable file (forces exit 2 + matches)."""
for i in range(4):
(tmp_path / f"f{i}.txt").write_text(f"needle line {i}\n")
sub = tmp_path / "sub"
sub.mkdir()
locked = sub / "locked.txt"
locked.write_text("needle in locked\n")
os.chmod(locked, 0o000)
yield tmp_path
os.chmod(locked, 0o755) # let pytest clean up tmp_path
# Run every test once per available backend method.
_METHODS = ["_search_with_grep"]
if shutil.which("rg"):
_METHODS.append("_search_with_rg")
def _search(ops, method, pattern, path, **kw):
fn = getattr(ops, method)
return fn(pattern, str(path), kw.get("file_glob"), kw.get("limit", 50),
kw.get("offset", 0), kw.get("output_mode", "content"),
kw.get("context", 0))
@pytest.mark.parametrize("method", _METHODS)
class TestSearchErrorGuard:
def test_happy_path_returns_matches(self, method, match_tree):
res = _search(_ops(match_tree), method, "needle", match_tree)
assert res.error is None
assert len(res.matches) == 5
def test_hard_error_is_surfaced(self, method, match_tree):
# An invalid regex makes rg/grep exit 2 with only diagnostics in
# stdout. The guard MUST surface it — not return empty matches.
res = _search(_ops(match_tree), method, "[", match_tree)
assert res.error is not None, "search error was silently swallowed"
assert "Search failed" in res.error
assert not res.matches
def test_partial_error_keeps_matches(self, method, partial_error_tree):
# rg/grep exit 2 because of the unreadable file, but the readable
# files matched. Those matches must be preserved, not discarded.
res = _search(_ops(partial_error_tree), method, "needle", partial_error_tree)
assert res.error is None, f"partial error wrongly surfaced: {res.error!r}"
assert len(res.matches) >= 4
def test_no_match_is_empty_not_error(self, method, match_tree):
res = _search(_ops(match_tree), method, "zzznomatchzzz", match_tree)
assert res.error is None
assert not res.matches
def test_truncation_no_false_error(self, method, tmp_path):
# head truncates a large result set. With pipefail, grep exits 141
# (SIGPIPE) on truncation; the strict `== 2` guard must ignore it.
big = tmp_path / "big.txt"
big.write_text("".join(f"needle {i}\n" for i in range(3000)))
res = _search(_ops(tmp_path), method, "needle", tmp_path, limit=5)
assert res.error is None, f"truncated success wrongly errored: {res.error!r}"
assert len(res.matches) == 5
def test_files_only_excludes_diagnostics(self, method, partial_error_tree):
# files_only mode must not list a diagnostic line as a fake file path.
res = _search(_ops(partial_error_tree), method, "needle",
partial_error_tree, output_mode="files_only")
assert res.error is None
assert res.files, "expected matching files"
assert all("Permission denied" not in f and "locked.txt" not in f
for f in res.files), f"diagnostic leaked into files: {res.files}"
def test_count_mode_with_partial_error(self, method, partial_error_tree):
res = _search(_ops(partial_error_tree), method, "needle",
partial_error_tree, output_mode="count")
assert res.error is None
assert res.total_count >= 4
class TestSplitToolDiagnostics:
"""Unit coverage for the shape-based diagnostic/payload splitter."""
def test_pure_error_has_empty_payload(self):
out = "rg: regex parse error:\n (?:[)\n ^\nerror: unclosed character class\n"
diagnostics, payload = _split_tool_diagnostics(out)
assert payload.strip() == ""
assert "regex parse error" in diagnostics
def test_partial_error_separates_matches(self):
out = ("rg: sub/locked.txt: Permission denied (os error 13)\n"
"a.txt:1:needle here\nb.txt:2:needle there\n")
diagnostics, payload = _split_tool_diagnostics(out)
assert "Permission denied" in diagnostics
assert "a.txt:1:needle here" in payload
assert "b.txt:2:needle there" in payload
assert "Permission denied" not in payload
def test_files_only_is_payload(self):
diagnostics, payload = _split_tool_diagnostics("src/a.py\nsrc/b.py\n")
assert diagnostics == ""
assert payload == "src/a.py\nsrc/b.py"
def test_count_lines_are_payload(self):
diagnostics, payload = _split_tool_diagnostics("src/a.py:3\nsrc/b.py:1\n")
assert diagnostics == ""
assert "src/a.py:3" in payload
def test_context_lines_and_separator_are_payload(self):
out = "a.py:5:hit\na.py-6-after\n--\nb.py:9:hit\n"
diagnostics, payload = _split_tool_diagnostics(out)
assert diagnostics == ""
assert "--" in payload
assert "a.py-6-after" in payload
-142
View File
@@ -1,142 +0,0 @@
"""Tests for the desktop remote self-update RPCs in tui_gateway.
``update.start`` spawns ``hermes update`` detached on the gateway's own host
(so a desktop window driving a REMOTE backend can update that box), and
``update.status`` reports progress via the namespaced ``.desktop_update_*``
marker files. See the /update desktop slash command + ``store/remote-update.ts``.
"""
from __future__ import annotations
import importlib
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture()
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
yield home
@pytest.fixture()
def server(hermes_home):
with patch.dict(
"sys.modules",
{
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
},
):
mod = importlib.import_module("tui_gateway.server")
yield mod
mod._methods.clear()
importlib.reload(mod)
def _call(server, method, **params):
return server._methods[method](1, params)
def test_status_is_idle_with_no_markers(server):
result = _call(server, "update.status")["result"]
assert result == {
"running": False,
"finished": False,
"exit_code": None,
"output": "",
}
def test_status_reports_finished_with_exit_code(server):
server._DESKTOP_UPDATE_OUTPUT.write_text("pulling…\nAlready up to date.")
server._DESKTOP_UPDATE_EXIT_CODE.write_text("0")
result = _call(server, "update.status")["result"]
assert result["finished"] is True
assert result["exit_code"] == 0
assert result["running"] is False
assert "up to date" in result["output"]
def test_status_running_when_pending_without_exit(server):
server._DESKTOP_UPDATE_PENDING.write_text("{}")
result = _call(server, "update.status")["result"]
assert result["running"] is True
assert result["finished"] is False
def test_start_spawns_detached_and_writes_pending(server):
with patch.object(server.subprocess, "Popen") as popen:
result = _call(server, "update.start")["result"]
assert result["started"] is True
assert server._DESKTOP_UPDATE_PENDING.exists()
# Cleared so a stale prior result can't be read as this run's status.
assert not server._DESKTOP_UPDATE_EXIT_CODE.exists()
popen.assert_called_once()
def test_start_is_idempotent_while_running(server):
server._DESKTOP_UPDATE_PENDING.write_text("{}") # in flight, no exit code yet
with patch.object(server.subprocess, "Popen") as popen:
result = _call(server, "update.start")["result"]
assert result.get("already_running") is True
popen.assert_not_called()
def test_start_blocked_on_managed_install(server):
with patch("hermes_cli.config.is_managed", return_value=True):
resp = _call(server, "update.start")
assert "error" in resp
assert "managed" in resp["error"]["message"].lower()
# ── gateway.restart ──────────────────────────────────────────────────────
def test_restart_schedules_reexec_thread(server):
with patch.object(server.threading, "Thread") as thread:
result = _call(server, "gateway.restart")["result"]
assert result["restarting"] is True
thread.assert_called_once()
assert thread.call_args.kwargs.get("daemon") is True
def test_restart_reexecs_in_place_on_posix(server):
captured: dict = {}
class _FakeThread:
def __init__(self, target=None, **_kw):
captured["target"] = target
def start(self): # noqa: D401 - thread shim
pass
with patch.object(server.threading, "Thread", _FakeThread):
_call(server, "gateway.restart")
with patch.object(server.time, "sleep"), patch.object(
server.os, "execv"
) as execv, patch.object(server.sys, "platform", "linux"):
captured["target"]()
execv.assert_called_once()
def test_restart_blocked_on_managed_install(server):
with patch("hermes_cli.config.is_managed", return_value=True):
resp = _call(server, "gateway.restart")
assert "error" in resp
assert "managed" in resp["error"]["message"].lower()
+18 -106
View File
@@ -285,63 +285,6 @@ class ExecuteResult:
exit_code: int = 0
def _split_tool_diagnostics(output: str) -> tuple[str, str]:
"""Separate rg/grep diagnostic lines from real match output.
``_exec`` runs commands with ``stderr=subprocess.STDOUT``, so error and
warning text from ``rg``/``grep`` is interleaved with match lines in a
single stream. Diagnostics must not be parsed as matches, and on a hard
failure they are the error message to surface.
Returns ``(diagnostics, payload)`` where ``payload`` contains only lines
that look like real search output — a match line (``file:line:content``),
a files-only path, a count line, or a context line/separator. Everything
else (tool-prefixed errors, rg's multi-line ``regex parse error`` block
with its indented carets, blank lines) is folded into ``diagnostics``.
Classifying by *shape* rather than by error prefix is what lets the
exit-2 guard distinguish a pure failure (no usable payload → surface the
error) from a partial failure (some files matched, one was unreadable →
keep the matches). It also means error text can never be mis-parsed as a
match, a latent bug that predates the exit-code fix.
"""
diagnostics: list[str] = []
payload: list[str] = []
for line in output.split('\n'):
if not line.strip():
continue
# Tool diagnostics always carry the "<tool>: " prefix (e.g.
# "rg: <file>: Permission denied", "grep: Invalid regular
# expression", "rg: regex parse error:"). Check this first: a real
# match path can legitimately contain "-<digit>" (e.g. a tmp dir like
# ".../pytest-686/..."), which the shape regex would otherwise treat
# as a match line.
stripped = line.lstrip()
if stripped.startswith("rg: ") or stripped.startswith("grep: "):
diagnostics.append(line)
continue
# Otherwise classify by output shape. rg's regex-parse-error block
# also emits an indented caret line and a trailing "error: ..." line
# with no tool prefix; neither matches a search-output shape, so they
# fall through to diagnostics.
# match / count : "<path>:<...>" (has a colon; rg -c uses path:count)
# files_only : "<path>" (no whitespace, no leading colon)
# context line : "<path>-<line>-" or the "--" group separator
if line == "--" or _SEARCH_OUTPUT_RE.match(line):
payload.append(line)
else:
diagnostics.append(line)
return '\n'.join(diagnostics), '\n'.join(payload)
# A real rg/grep output line starts with a path token and is followed by a
# ``:`` (match/count), a ``-`` (context), or nothing (files_only). Tool
# diagnostics ("rg: ...", "grep: ...", "error: ...", indented carets) never
# match because the path token forbids whitespace and a leading tool prefix
# like "rg" is followed by ": " (space) which the negated class rejects.
_SEARCH_OUTPUT_RE = re.compile(r'^([A-Za-z]:)?[^\s:][^\n]*?[:\-]\d|^[^\s:][^\s]*$')
def _parse_search_context_line(line: str) -> tuple[str, int, str] | None:
"""Parse grep/rg context output in ``path-line-content`` format.
@@ -2095,40 +2038,24 @@ class ShellFileOperations(FileOperations):
fetch_limit = limit + offset + 200 if context > 0 else limit + offset
cmd_parts.extend(["|", "head", "-n", str(fetch_limit)])
# `set -o pipefail` so rg's exit status propagates through `| head`.
# Without it the pipeline reports head's status (0), masking rg's
# error code (2) and making the guard below unreachable. rg handles a
# truncating head cleanly (exit 0 on SIGPIPE), so pipefail does not
# introduce false errors on a successful-but-truncated search.
cmd = "set -o pipefail; " + " ".join(cmd_parts)
cmd = " ".join(cmd_parts)
result = self._exec(cmd, timeout=60)
# _exec merges stderr into stdout (stderr=subprocess.STDOUT), so rg's
# diagnostic lines ("rg: <file>: <error>", "rg: regex parse error:")
# are interleaved with match output. Split them out: diagnostics must
# not be parsed as matches, and on a hard error they ARE the message.
diagnostics, payload = _split_tool_diagnostics(result.stdout)
# rg exit codes: 0=matches found, 1=no matches, 2=error. rg returns 2
# even on partial errors (e.g. one unreadable file in a tree that
# otherwise matched), so only surface an error when exit==2 AND no
# usable match payload remains. Otherwise we keep the real matches.
if result.exit_code == 2 and not payload.strip():
error_msg = diagnostics.strip() or result.stdout.strip() or "Search error"
# rg exit codes: 0=matches found, 1=no matches, 2=error
if result.exit_code == 2 and not result.stdout.strip():
error_msg = result.stderr.strip() if hasattr(result, 'stderr') and result.stderr else "Search error"
return SearchResult(error=f"Search failed: {error_msg}", total_count=0)
# Parse the diagnostic-free payload so error text never becomes a match.
stdout = payload
# Parse results based on output mode
if output_mode == "files_only":
all_files = [f for f in stdout.strip().split('\n') if f]
all_files = [f for f in result.stdout.strip().split('\n') if f]
total = len(all_files)
page = all_files[offset:offset + limit]
return SearchResult(files=page, total_count=total)
elif output_mode == "count":
counts = {}
for line in stdout.strip().split('\n'):
for line in result.stdout.strip().split('\n'):
if ':' in line:
parts = line.rsplit(':', 1)
if len(parts) == 2:
@@ -2147,7 +2074,7 @@ class ShellFileOperations(FileOperations):
# so naive split(":") breaks. Use regex to handle both platforms.
_match_re = re.compile(r'^([A-Za-z]:)?(.*?):(\d+):(.*)$')
matches = []
for line in stdout.strip().split('\n'):
for line in result.stdout.strip().split('\n'):
if not line or line == "--":
continue
@@ -2211,38 +2138,23 @@ class ShellFileOperations(FileOperations):
fetch_limit = limit + offset + (200 if context > 0 else 0)
cmd_parts.extend(["|", "head", "-n", str(fetch_limit)])
# `set -o pipefail` so grep's exit status propagates through `| head`
# (without it the pipeline reports head's 0, masking grep's error 2).
# A truncating head makes grep exit 141 (SIGPIPE) on an otherwise
# successful search; the strict `== 2` guard below ignores that, so
# pipefail does not turn truncated results into false errors.
cmd = "set -o pipefail; " + " ".join(cmd_parts)
cmd = " ".join(cmd_parts)
result = self._exec(cmd, timeout=60)
# _exec merges stderr into stdout, so grep's diagnostic lines
# ("grep: <file>: <error>") are interleaved with matches. Split them
# out so they're never parsed as matches and so a hard error has a
# clean message.
diagnostics, payload = _split_tool_diagnostics(result.stdout)
# grep exit codes: 0=matches found, 1=no matches, 2=error. grep
# returns 2 on partial errors (e.g. an unreadable file) even when
# other files matched, so only surface an error when exit==2 AND no
# usable match payload remains.
if result.exit_code == 2 and not payload.strip():
error_msg = diagnostics.strip() or result.stdout.strip() or "Search error"
# grep exit codes: 0=matches found, 1=no matches, 2=error
if result.exit_code == 2 and not result.stdout.strip():
error_msg = result.stderr.strip() if hasattr(result, 'stderr') and result.stderr else "Search error"
return SearchResult(error=f"Search failed: {error_msg}", total_count=0)
stdout = payload
if output_mode == "files_only":
all_files = [f for f in stdout.strip().split('\n') if f]
all_files = [f for f in result.stdout.strip().split('\n') if f]
total = len(all_files)
page = all_files[offset:offset + limit]
return SearchResult(files=page, total_count=total)
elif output_mode == "count":
counts = {}
for line in stdout.strip().split('\n'):
for line in result.stdout.strip().split('\n'):
if ':' in line:
parts = line.rsplit(':', 1)
if len(parts) == 2:
@@ -2260,7 +2172,7 @@ class ShellFileOperations(FileOperations):
# so naive split(":") breaks. Use regex to handle both platforms.
_match_re = re.compile(r'^([A-Za-z]:)?(.*?):(\d+):(.*)$')
matches = []
for line in stdout.strip().split('\n'):
for line in result.stdout.strip().split('\n'):
if not line or line == "--":
continue
-196
View File
@@ -8398,199 +8398,3 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5002, "command timed out (30s)")
except Exception as e:
return _err(rid, 5003, str(e))
# ── Methods: update.start / update.status ────────────────────────────
# Self-update for a REMOTE backend. The desktop app's native (Electron) updater
# can only patch the LOCAL checkout, so when a desktop window drives a backend
# on another host the only way to update THAT box is to have the gateway run
# `hermes update` on itself — mirroring the messaging gateway's /update. We
# spawn it detached (setsid) so it survives any restart, and the desktop polls
# update.status across the disconnect/reconnect. Markers are namespaced
# (.desktop_update_*) so a co-running messaging gateway's update watcher/cleanup
# never collides with ours.
_DESKTOP_UPDATE_PENDING = _hermes_home / ".desktop_update_pending.json"
_DESKTOP_UPDATE_OUTPUT = _hermes_home / ".desktop_update_output.txt"
_DESKTOP_UPDATE_EXIT_CODE = _hermes_home / ".desktop_update_exit_code"
def _resolve_update_hermes_bin() -> "list[str] | None":
"""Resolve `hermes` as argv parts: PATH shim first, module fallback."""
import shutil
hermes_bin = shutil.which("hermes")
if hermes_bin:
return [hermes_bin]
try:
import importlib.util
if importlib.util.find_spec("hermes_cli") is not None:
return [sys.executable, "-m", "hermes_cli.main"]
except Exception:
pass
return None
@method("update.start")
def _(rid, params: dict) -> dict:
import json as _json
import shlex as _shlex
import shutil as _shutil
from datetime import datetime
try:
from hermes_cli.config import is_managed
if is_managed():
return _err(rid, 4030, "This managed install can't self-update.")
except Exception:
pass
project_root = Path(__file__).resolve().parent.parent
if not (project_root / ".git").exists():
return _err(rid, 4031, "Backend is not a git checkout; can't self-update.")
hermes_cmd = _resolve_update_hermes_bin()
if not hermes_cmd:
return _err(rid, 4032, "Could not locate the hermes command on the backend.")
# An update already in flight (pending written, no exit code yet): don't
# spawn a second updater — just let the caller poll the existing one.
if _DESKTOP_UPDATE_PENDING.exists() and not _DESKTOP_UPDATE_EXIT_CODE.exists():
return _ok(rid, {"started": True, "already_running": True})
_DESKTOP_UPDATE_EXIT_CODE.unlink(missing_ok=True)
_DESKTOP_UPDATE_OUTPUT.unlink(missing_ok=True)
_tmp = _DESKTOP_UPDATE_PENDING.with_suffix(".tmp")
_tmp.write_text(_json.dumps({"source": "desktop", "timestamp": datetime.now().isoformat()}))
_tmp.replace(_DESKTOP_UPDATE_PENDING)
# Plain `hermes update` (no --gateway): with no TTY it takes the
# non-interactive path (safe config migrations auto-applied, local changes
# handled per `updates.non_interactive_local_changes`). --gateway would wait
# on file-IPC prompts that nothing in the tui_gateway answers, so it'd hang.
try:
if sys.platform == "win32":
import textwrap
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
helper = textwrap.dedent(
"""
import os, subprocess, sys
output_path = sys.argv[1]
exit_code_path = sys.argv[2]
cmd = sys.argv[3:]
env = dict(os.environ)
env["PYTHONUNBUFFERED"] = "1"
with open(output_path, "wb") as f:
proc = subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT, env=env)
rc = proc.wait()
with open(exit_code_path, "w") as f:
f.write(str(rc))
"""
).strip()
subprocess.Popen(
[
sys.executable, "-c", helper,
str(_DESKTOP_UPDATE_OUTPUT), str(_DESKTOP_UPDATE_EXIT_CODE),
*hermes_cmd, "update",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**windows_detach_popen_kwargs(),
)
else:
hermes_cmd_str = " ".join(_shlex.quote(part) for part in hermes_cmd)
update_cmd = (
f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update"
f" > {_shlex.quote(str(_DESKTOP_UPDATE_OUTPUT))} 2>&1; "
f"rc=$?; printf '%s' \"$rc\" > {_shlex.quote(str(_DESKTOP_UPDATE_EXIT_CODE))}"
)
setsid_bin = _shutil.which("setsid")
argv = ([setsid_bin] if setsid_bin else []) + ["bash", "-c", update_cmd]
subprocess.Popen(
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except Exception as e:
_DESKTOP_UPDATE_PENDING.unlink(missing_ok=True)
_DESKTOP_UPDATE_EXIT_CODE.unlink(missing_ok=True)
return _err(rid, 5030, f"Failed to start update: {e}")
return _ok(rid, {"started": True})
@method("update.status")
def _(rid, params: dict) -> dict:
output = ""
try:
if _DESKTOP_UPDATE_OUTPUT.exists():
output = _DESKTOP_UPDATE_OUTPUT.read_text(errors="replace")[-4000:]
except Exception:
output = ""
finished = _DESKTOP_UPDATE_EXIT_CODE.exists()
exit_code = None
if finished:
try:
raw = _DESKTOP_UPDATE_EXIT_CODE.read_text().strip()
exit_code = int(raw) if raw else None
except Exception:
exit_code = None
return _ok(
rid,
{
"running": _DESKTOP_UPDATE_PENDING.exists() and not finished,
"finished": finished,
"exit_code": exit_code,
"output": output,
},
)
@method("gateway.restart")
def _(rid, params: dict) -> dict:
"""Re-exec the backend host so freshly-pulled code is actually loaded.
`update.start` only rewrites the checkout on disk the running process is
still executing the old code. For a LOCAL backend the desktop's Electron
updater handles the relaunch; for a REMOTE backend nothing else can, so the
gateway restarts itself. We re-exec in place (`os.execv`, same PID) after a
short delay so this RPC's response flushes before the socket drops; the
desktop rides the disconnect out via its reconnect loop and re-polls
`update.status`.
Deployment-agnostic by design: in-place re-exec needs no external
supervisor (systemd, pm2, ) it works for a bare `hermes` process just as
well as a supervised one. Refused for managed installs, which own their own
lifecycle.
"""
try:
from hermes_cli.config import is_managed
if is_managed():
return _err(rid, 4033, "This managed install can't restart itself.")
except Exception:
pass
def _reexec() -> None:
# Let the JSON-RPC reply (and any in-flight event) flush before the
# transport dies under us.
time.sleep(0.4)
argv = [sys.executable, *sys.argv]
try:
if sys.platform == "win32":
# The console-script shim isn't a real Win32 exe, so os.execv
# can't replace it in place — spawn a fresh process, then exit.
subprocess.Popen(argv)
os._exit(0)
else:
os.execv(sys.executable, argv)
except Exception:
logger.exception("gateway.restart: re-exec failed")
threading.Thread(target=_reexec, name="gateway-restart", daemon=True).start()
return _ok(rid, {"restarting": True})