Compare commits

..
Author SHA1 Message Date
Austin Pickett ea0efea2bd fix(cli): /plugins shows installed-but-not-enabled plugins
The /plugins slash command read from the live PluginManager, which only
knows about *loaded* plugins. A freshly-installed plugin that hadn't been
enabled yet showed 'No plugins installed. Drop plugin directories into
~/.hermes/plugins/' — even though it was on disk and a valid plugin.

Switch to the same disk-discovery path as 'hermes plugins list'
(_discover_all_plugins + enabled/disabled sets + _plugin_status), so an
installed plugin now appears with its activation state ([not enabled],
enabled, or disabled) plus the exact enable command.

Default the quick /plugins view to user-installed plugins and summarize
bundled providers/platforms on one line (the full catalog stays behind
'hermes plugins list') so the output isn't drowned by 60+ bundled
provider plugins.
2026-06-09 00:13:38 -04:00
63 changed files with 604 additions and 2312 deletions
+1 -5
View File
@@ -821,7 +821,6 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
capture_output=True,
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
except (OSError, subprocess.TimeoutExpired):
logger.debug("Keychain: security command not available or timed out")
@@ -1164,10 +1163,7 @@ def run_oauth_setup_token() -> Optional[str]:
"Install it with: npm install -g @anthropic-ai/claude-code"
)
# Run interactively — stdin/stdout/stderr inherited so the user can
# complete the OAuth login prompt. Must keep inherited stdin; the TUI-EOF
# concern does not apply to an interactive login the user explicitly
# invokes. noqa: subprocess-stdin
# Run interactively — stdin/stdout/stderr inherited so user can interact
try:
subprocess.run([claude_path, "setup-token"])
except (KeyboardInterrupt, EOFError):
-2
View File
@@ -290,7 +290,6 @@ def _expand_git_reference(
capture_output=True,
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return f"{ref.raw}: git command timed out (30s)", None
@@ -483,7 +482,6 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
-3
View File
@@ -262,7 +262,6 @@ def _install_npm(
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
@@ -311,7 +310,6 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
text=True,
timeout=600,
env=env,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
@@ -349,7 +347,6 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
-2
View File
@@ -274,7 +274,6 @@ def _platform_asset_name() -> str:
capture_output=True,
text=True,
timeout=2,
stdin=subprocess.DEVNULL,
)
if "musl" in (res.stdout + res.stderr).lower():
libc = "musl"
@@ -526,7 +525,6 @@ def _run_bws_list(
capture_output=True,
text=True,
timeout=_BWS_RUN_TIMEOUT,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
-1
View File
@@ -74,7 +74,6 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
text=True,
timeout=max(1, int(timeout)),
check=False,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return f"[inline-shell timeout after {timeout}s: {command}]"
-1
View File
@@ -378,7 +378,6 @@ def check_codex_binary(
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
return False, (
@@ -4,8 +4,6 @@ import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $sessions, setSessions } from '@/store/session'
import { $connection } from '@/store/session'
import type { ComposerAttachment } from '@/store/composer'
import type { SessionInfo } from '@/types/hermes'
import { usePromptActions } from './use-prompt-actions'
@@ -44,10 +42,7 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
interface HarnessHandle {
steerPrompt: (text: string) => Promise<boolean>
submitText: (
text: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean>
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
}
function Harness({
@@ -319,92 +314,3 @@ describe('usePromptActions steerPrompt', () => {
expect(requestGateway).not.toHaveBeenCalled()
})
})
describe('usePromptActions file attachment sync', () => {
afterEach(() => {
cleanup()
$connection.set(null)
vi.restoreAllMocks()
})
function fileAttachment(): ComposerAttachment {
return {
id: 'file:report.txt',
kind: 'file',
label: 'report.txt',
path: '/Users/alice/Downloads/report.txt',
refText: '@file:`/Users/alice/Downloads/report.txt`'
}
}
it('uploads file bytes via file.attach on a remote gateway and submits the rewritten ref', async () => {
// Remote gateway can't read the client-disk path, so the desktop must upload
// the bytes and submit the workspace-relative ref the gateway hands back —
// not the original /Users/... path (which would dead-end as "outside the
// allowed workspace").
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') }
})
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return {
attached: true,
path: '/remote/work/.hermes/desktop-attachments/report.txt',
ref_text: '@file:.hermes/desktop-attachments/report.txt',
uploaded: true
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('convert this to epub', { attachments: [fileAttachment()] })
expect(ok).toBe(true)
expect(calls.map(c => c.method)).toEqual(['file.attach', 'prompt.submit'])
expect(calls[0]?.params).toMatchObject({
session_id: RUNTIME_SESSION_ID,
path: '/Users/alice/Downloads/report.txt',
name: 'report.txt',
data_url: 'data:text/plain;base64,aGVsbG8='
})
expect(calls[1]?.params).toEqual({
session_id: RUNTIME_SESSION_ID,
text: '@file:.hermes/desktop-attachments/report.txt\n\nconvert this to epub'
})
})
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
$connection.set({ mode: 'local' } as never)
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
expect(ok).toBe(true)
expect(calls[0]?.method).toBe('file.attach')
// Local mode sends no data_url — the gateway shares this disk.
expect(calls[0]?.params).not.toHaveProperty('data_url')
expect(calls[1]).toEqual({
method: 'prompt.submit',
params: { session_id: RUNTIME_SESSION_ID, text: '@file:data/report.txt\n\nsummarize' }
})
})
})
@@ -47,7 +47,6 @@ import {
import type {
ClientSessionState,
FileAttachResponse,
ImageAttachResponse,
SessionSteerResponse,
SessionTitleResponse,
@@ -104,20 +103,6 @@ async function readImageForRemoteAttach(
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
}
// Read a non-image file as a data URL for upload via file.attach. Returns null
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
const reader = window.hermesDesktop?.readFileDataUrl
if (!reader) {
return null
}
const dataUrl = await reader(filePath)
return dataUrl || null
}
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
@@ -227,114 +212,62 @@ export function usePromptActions({
[selectedStoredSessionIdRef, updateSessionState]
)
const syncAttachmentsForSubmit = useCallback(
const syncImageAttachmentsForSubmit = useCallback(
async (
sessionId: string,
attachments: ComposerAttachment[],
options: { updateComposerAttachments?: boolean } = {}
): Promise<ComposerAttachment[]> => {
) => {
const updateComposerAttachments = options.updateComposerAttachments ?? true
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
const remote = $connection.get()?.mode === 'remote'
const synced: ComposerAttachment[] = []
for (const attachment of attachments) {
// Already-synced or pathless refs (terminal, url, etc.) pass through.
if (!attachment.path || attachment.attachedSessionId === sessionId) {
synced.push(attachment)
for (const attachment of images) {
if (attachment.attachedSessionId === sessionId) {
continue
}
if (attachment.kind === 'image') {
let result: ImageAttachResponse
let result: ImageAttachResponse
if (remote) {
// The gateway is on another machine — it can't read attachment.path
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
const payload = await readImageForRemoteAttach(attachment.path)
if (remote) {
// The gateway is on another machine — it can't read attachment.path
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
if (!payload) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(`Could not read ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
if (!payload) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(`Could not read ${label}`)
}
if (!result.attached) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(result.message || `Could not attach ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
}
const attachedPath = result.path || attachment.path
const nextAttachment: ComposerAttachment = {
if (!result.attached) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(result.message || `Could not attach ${label}`)
}
const attachedPath = result.path || attachment.path
if (updateComposerAttachments) {
addComposerAttachment({
...attachment,
id: attachment.id,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
attachedSessionId: sessionId
}
if (updateComposerAttachments) {
addComposerAttachment(nextAttachment)
}
synced.push(nextAttachment)
continue
}
if (attachment.kind === 'file') {
// Non-image file refs are @file: paths the gateway reads with its file
// tools. On a remote gateway the desktop path doesn't exist there, so
// upload the bytes; the gateway stages them into the session workspace
// and hands back a workspace-relative ref that actually resolves.
// Local mode can pass the path directly (gateway shares this disk).
const dataUrl = remote ? await readFileDataUrlForAttach(attachment.path) : null
if (remote && !dataUrl) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(`Could not read ${label}`)
}
const result = await requestGateway<FileAttachResponse>('file.attach', {
session_id: sessionId,
path: attachment.path,
name: attachment.label || pathLabel(attachment.path),
...(dataUrl ? { data_url: dataUrl } : {})
})
if (!result.attached || !result.ref_text) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(result.message || `Could not attach ${label}`)
}
const nextAttachment: ComposerAttachment = {
...attachment,
id: attachment.id,
refText: result.ref_text,
attachedSessionId: sessionId
}
if (updateComposerAttachments) {
addComposerAttachment(nextAttachment)
}
synced.push(nextAttachment)
continue
}
synced.push(attachment)
}
return synced
},
[requestGateway]
)
@@ -345,42 +278,35 @@ export function usePromptActions({
const usingComposerAttachments = !options?.attachments
const attachments = options?.attachments ?? $composerAttachments.get()
const contextRefs = attachments
.map(a => a.refText)
.filter(Boolean)
.join('\n')
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
const hasImage = attachments.some(a => a.kind === 'image')
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
// Refs are recomputed after sync (file.attach rewrites @file: refs to
// workspace-relative paths the remote gateway can resolve). Seed the
// optimistic message with the pre-sync refs, then rewrite once synced.
let attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
const buildContextText = (atts: ComposerAttachment[]): string => {
const contextRefs = atts
.map(a => a.refText)
.filter(Boolean)
.join('\n')
return (
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(atts.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
)
}
const text =
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(hasImage ? 'What do you see in this image?' : '')
// Queue drains fire on the busy→false settle edge, where busyRef (synced
// from $busy by a separate effect) may still read true — honoring it would
// bounce the drained send. The drain lock serializes them; the user path
// keeps the guard so a stray Enter mid-turn can't double-submit.
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
if (!text || (!options?.fromQueue && busyRef.current)) {
return false
}
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const buildUserMessage = (): ChatMessage => ({
const userMessage: ChatMessage = {
id: optimisticId,
role: 'user',
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs
})
}
const releaseBusy = () => {
setMutableRef(busyRef, false)
@@ -397,7 +323,7 @@ export function usePromptActions({
...state,
messages: state.messages.some(m => m.id === optimisticId)
? state.messages
: [...state.messages, buildUserMessage()],
: [...state.messages, userMessage],
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
@@ -410,18 +336,6 @@ export function usePromptActions({
selectedStoredSessionIdRef.current
)
// After sync rewrites refs, refresh the optimistic message in place so the
// transcript shows the resolved @file: ref rather than the local path.
const rewriteOptimistic = (sid: string) =>
updateSessionState(
sid,
state => ({
...state,
messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message))
}),
selectedStoredSessionIdRef.current
)
const dropOptimistic = (sid: null | string) => {
if (!sid) {
setMessages(current => current.filter(m => m.id !== optimisticId))
@@ -452,7 +366,7 @@ export function usePromptActions({
if (sessionId) {
seedOptimistic(sessionId)
} else {
setMessages(current => [...current, buildUserMessage()])
setMessages(current => [...current, userMessage])
}
if (!sessionId) {
@@ -478,14 +392,9 @@ export function usePromptActions({
}
try {
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
await syncImageAttachmentsForSubmit(sessionId, attachments, {
updateComposerAttachments: usingComposerAttachments
})
// Rewrite the optimistic message + prompt text with the synced refs so
// the gateway receives @file: paths that resolve in its workspace.
attachmentRefs = syncedAttachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
rewriteOptimistic(sessionId)
const text = buildContextText(syncedAttachments)
await requestGateway('prompt.submit', { session_id: sessionId, text })
if (usingComposerAttachments) {
@@ -533,7 +442,7 @@ export function usePromptActions({
createBackendSessionForSend,
requestGateway,
selectedStoredSessionIdRef,
syncAttachmentsForSubmit,
syncImageAttachmentsForSubmit,
updateSessionState
]
)
-14
View File
@@ -27,20 +27,6 @@ export interface ImageDetachResponse {
count?: number
}
export interface FileAttachResponse {
attached?: boolean
message?: string
// Gateway-side absolute path the file was staged to.
path?: string
// Workspace-relative path used to build ref_text.
ref_path?: string
// Rewritten @file: ref that resolves on the gateway (workspace-relative).
ref_text?: string
// True when bytes/host file were copied into the session workspace.
uploaded?: boolean
name?: string
}
export interface SlashExecResponse {
output?: string
warning?: string
+1 -2
View File
@@ -88,8 +88,7 @@ function isUpdateToastSnoozed(): boolean {
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
// against. The backend reports its own value in session runtime info; a lower
// value (or none — a pre-GUI checkout) means GUI<->backend skew.
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
const REQUIRED_BACKEND_CONTRACT = 2
const REQUIRED_BACKEND_CONTRACT = 1
const SKEW_TOAST_ID = 'backend-contract-skew'
/**
+2 -2
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2023",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
+89 -30
View File
@@ -582,21 +582,38 @@ def load_cli_config() -> Dict[str, Any]:
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
terminal_config.pop("cwd", None)
# Derive the config→env bridge from the single source of truth in
# hermes_cli/config.py so this path can never drift from the gateway
# bridge or `hermes config set` (the docker_extra_args / modal_mode
# silent-drop bug class). Two CLI-specific deltas on top of the shared
# map: (1) the legacy ``env_type`` alias for ``backend`` (cli copies
# backend→env_type above, so we key TERMINAL_ENV off env_type here);
# (2) ``sudo_password`` → ``$SUDO_PASSWORD``, a cross-backend credential
# that isn't a terminal.* setting.
from hermes_cli.config import TERMINAL_CONFIG_ENV_MAP as _SHARED_TERMINAL_ENV_MAP
env_mappings = {
("env_type" if _k == "backend" else _k): _v
for _k, _v in _SHARED_TERMINAL_ENV_MAP.items()
"env_type": "TERMINAL_ENV",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
# SSH config
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
"ssh_key": "TERMINAL_SSH_KEY",
# Container resource config (docker, singularity, modal, daytona -- ignored for local/ssh)
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
"container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"docker_env": "TERMINAL_DOCKER_ENV",
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
# Persistent shell (non-local backends)
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
# Sudo support (works with all backends)
"sudo_password": "SUDO_PASSWORD",
}
env_mappings["sudo_password"] = "SUDO_PASSWORD"
# Bridge config → env vars for terminal_tool. TERMINAL_CWD is force-exported
# UNLESS we're inside a gateway process (detected by _HERMES_GATEWAY marker)
@@ -7285,24 +7302,66 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._handle_browser_command(cmd_original)
elif canonical == "plugins":
try:
from hermes_cli.plugins import get_plugin_manager
mgr = get_plugin_manager()
plugins = mgr.list_plugins()
if not plugins:
print("No plugins installed.")
print(f"Drop plugin directories into {display_hermes_home()}/plugins/ to get started.")
# Discover from disk (bundled + user), matching `hermes plugins
# list` — so installed-but-not-enabled plugins are visible here
# too. The plugin manager only knows about *loaded* plugins, so
# using it alone made freshly-installed, not-yet-enabled plugins
# look like "nothing installed".
from hermes_cli.plugins_cmd import (
_discover_all_plugins,
_get_disabled_set,
_get_enabled_set,
_plugin_status,
)
entries = _discover_all_plugins()
enabled = _get_enabled_set()
disabled = _get_disabled_set()
# `/plugins` is a quick glance — default to user-installed
# plugins (what the user actually added). Bundled provider/
# platform plugins are summarized on one line; the full
# catalog lives behind `hermes plugins list`.
user_entries = [e for e in entries if e[3] != "bundled"]
bundled_count = len(entries) - len(user_entries)
if not user_entries:
print("No user plugins installed.")
print(" Install one: hermes plugins install owner/repo")
print(f" Or drop a plugin directory into {display_hermes_home()}/plugins/")
if bundled_count:
print(f" ({bundled_count} bundled plugins available — see: hermes plugins list)")
else:
print(f"Plugins ({len(plugins)}):")
for p in plugins:
status = "" if p["enabled"] else ""
version = f" v{p['version']}" if p["version"] else ""
tools = f"{p['tools']} tools" if p["tools"] else ""
hooks = f"{p['hooks']} hooks" if p["hooks"] else ""
commands = f"{p['commands']} commands" if p.get("commands") else ""
parts = [x for x in [tools, hooks, commands] if x]
detail = f" ({', '.join(parts)})" if parts else ""
error = f"{p['error']}" if p["error"] else ""
print(f" {status} {p['name']}{version}{detail}{error}")
# Loaded-plugin details (tools/hooks/commands counts, errors)
# keyed by name, when available.
loaded: dict = {}
try:
from hermes_cli.plugins import get_plugin_manager
for p in get_plugin_manager().list_plugins():
loaded[p["name"]] = p
except Exception:
loaded = {}
print(f"User plugins ({len(user_entries)}):")
for name, version, _desc, source, _dir, key in sorted(user_entries):
state = _plugin_status(name, enabled, disabled, key=key)
glyph = {"enabled": "", "disabled": ""}.get(state, "")
ver = f" v{version}" if version else ""
info = loaded.get(name) or {}
bits = []
if info.get("tools"):
bits.append(f"{info['tools']} tools")
if info.get("hooks"):
bits.append(f"{info['hooks']} hooks")
if info.get("commands"):
bits.append(f"{info['commands']} commands")
detail = f" ({', '.join(bits)})" if bits else ""
label = "" if state == "enabled" else f" [{state}]"
error = f"{info['error']}" if info.get("error") else ""
print(f" {glyph} {name}{ver}{label}{detail}{error}")
if bundled_count:
print(f" (+{bundled_count} bundled — see: hermes plugins list)")
print(" Enable/disable: hermes plugins enable/disable <name>")
except Exception as e:
print(f"Plugin system error: {e}")
elif canonical == "rollback":
+3 -4
View File
@@ -1797,10 +1797,9 @@ class BasePlatformAdapter(ABC):
# ``format_message`` translates/preserves markdown fences into a real code
# block). Capability flag for markdown-aware presentation choices.
# Default False (plain-text platforms); markdown-rendering adapters set True.
# Tool-progress uses this to render a terminal command as a bare fenced code
# block (no language tag — Slack mrkdwn would print the tag as a literal
# first code line). Plain-text platforms fall back to the short truncated
# preview (see gateway/run.py progress_callback).
# Note: tool-progress deliberately does NOT use this to render a terminal
# command as a ```bash block — that exposed full commands in chat. Progress
# shows a short truncated preview only (see gateway/run.py progress_callback).
supports_code_blocks: bool = False
def __init__(self, config: PlatformConfig, platform: Platform):
+31 -68
View File
@@ -688,18 +688,7 @@ def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any:
# ordinary outputs. Only tools that intentionally create deliverable media
# artifacts should be eligible for automatic append when the model omits them
# from the final gateway reply.
_AUTO_APPEND_MEDIA_TOOL_NAMES = {
"text_to_speech",
"text_to_speech_tool",
"image_generate",
}
# Tools in this set return their deliverable artifact as a JSON payload with a
# local-file path field rather than a literal ``MEDIA:`` tag (e.g. image_generate
# returns ``{"success": true, "image": "/abs/path.png"}``). The auto-append path
# extracts the path from these fields so delivery is deterministic and does not
# depend on the model restating the path in its final reply.
_JSON_MEDIA_TOOL_PATH_FIELDS = ("host_image", "image", "agent_visible_image")
_AUTO_APPEND_MEDIA_TOOL_NAMES = {"text_to_speech", "text_to_speech_tool"}
# Extension-anchored MEDIA: matcher for tool results. Mirrors the dispatch-site
@@ -766,28 +755,10 @@ def _collect_auto_append_media_tags(
if tool_name_by_call_id.get(call_id) not in _AUTO_APPEND_MEDIA_TOOL_NAMES:
continue
content = str(msg.get("content") or "")
tool_name = tool_name_by_call_id.get(call_id)
# JSON-payload tools (image_generate) return a local-file path in a
# known field rather than a MEDIA: tag. Extract it so delivery is
# deterministic even when the model omits the path from its reply.
if tool_name == "image_generate" and "MEDIA:" not in content:
try:
payload = json.loads(content)
except Exception:
payload = None
if isinstance(payload, dict) and payload.get("success"):
for field in _JSON_MEDIA_TOOL_PATH_FIELDS:
path = payload.get(field)
if (isinstance(path, str)
and _TOOL_MEDIA_RE.fullmatch(f"MEDIA:{path}")
and path not in history_media_paths):
media_tags.append(f"MEDIA:{path}")
break
continue
if "MEDIA:" not in content:
continue
for match in _TOOL_MEDIA_RE.finditer(content):
path = match.group(1).strip().rstrip('",}')
path = match.group(1).strip().rstrip('\",}')
if path and path not in history_media_paths:
media_tags.append(f"MEDIA:{path}")
if "[[audio_as_voice]]" in content:
@@ -962,13 +933,33 @@ if _config_path.exists():
# config.yaml overrides .env for these since it's the documented config path.
_terminal_cfg = _cfg.get("terminal", {})
if _terminal_cfg and isinstance(_terminal_cfg, dict):
# Derive from the single source of truth in hermes_cli/config.py
# so the gateway bridge can never drift from the CLI bridge or
# `hermes config set` (the docker_extra_args / modal_mode
# silent-drop bug class). The gateway uses the canonical
# ``backend`` key (no legacy env_type alias) and no sudo_password,
# so the shared map maps over 1:1.
from hermes_cli.config import TERMINAL_CONFIG_ENV_MAP as _terminal_env_map
_terminal_env_map = {
"backend": "TERMINAL_ENV",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
"ssh_key": "TERMINAL_SSH_KEY",
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
"container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"docker_env": "TERMINAL_DOCKER_ENV",
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
}
for _cfg_key, _env_var in _terminal_env_map.items():
if _cfg_key in _terminal_cfg:
_val = _terminal_cfg[_cfg_key]
@@ -12980,33 +12971,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Build progress message with primary argument preview
from agent.display import get_tool_emoji
emoji = get_tool_emoji(tool_name, default="⚙️")
# Markdown-capable platforms render a terminal command as a fenced
# code block (full command, no truncation) instead of the compact
# `terminal: "cmd…"` preview. Gated on the adapter's
# ``supports_code_blocks`` capability so plain-text platforms keep
# the short line. No language tag is emitted — Slack mrkdwn renders
# the tag as a literal first code line ("bash"), and a bare fence
# renders correctly everywhere that supports blocks.
_code_block = None
try:
_progress_adapter = self.adapters.get(source.platform)
except Exception:
_progress_adapter = None
if (
getattr(_progress_adapter, "supports_code_blocks", False)
and tool_name == "terminal"
and isinstance(args, dict)
and isinstance(args.get("command"), str)
and args["command"].strip()
):
_code_block = f"{emoji} {tool_name}\n```\n{args['command'].rstrip()}\n```"
# Verbose mode: show detailed arguments, respects tool_preview_length
if progress_mode == "verbose":
if _code_block is not None:
progress_queue.put(_code_block)
return
if args:
from agent.display import get_tool_preview_max_len
_pl = get_tool_preview_max_len()
@@ -13027,11 +12994,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# "all" / "new" modes: short preview, respects tool_preview_length
# config (defaults to 40 chars when unset to keep gateway messages
# compact — unlike CLI spinners, these persist as permanent messages).
# Terminal commands on markdown platforms get the full fenced block
# built above instead of the truncated preview.
if _code_block is not None:
msg = _code_block
elif preview:
if preview:
from agent.display import get_tool_preview_max_len
_pl = get_tool_preview_max_len()
_cap = _pl if _pl > 0 else 40
+30 -92
View File
@@ -13,7 +13,6 @@ This module provides:
"""
import copy
import json
import logging
import os
import platform
@@ -5153,94 +5152,6 @@ def load_config_readonly() -> Dict[str, Any]:
return _load_config_impl(want_deepcopy=False)
TERMINAL_CONFIG_ENV_MAP = {
"backend": "TERMINAL_ENV",
"modal_mode": "TERMINAL_MODAL_MODE",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
"ssh_key": "TERMINAL_SSH_KEY",
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
"container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"docker_env": "TERMINAL_DOCKER_ENV",
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS",
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
}
def _terminal_env_value(value: Any) -> str:
if isinstance(value, (list, dict)):
return json.dumps(value)
return str(value)
def terminal_config_env_var_for_key(key: str) -> Optional[str]:
"""Return the env var mirrored by a ``terminal.*`` config key."""
prefix = "terminal."
if not key.startswith(prefix):
return None
return TERMINAL_CONFIG_ENV_MAP.get(key[len(prefix):])
def apply_terminal_config_to_env(
*,
env: Optional[Dict[str, str]] = None,
config: Optional[Dict[str, Any]] = None,
override: Optional[bool] = None,
) -> Dict[str, str]:
"""Bridge ``terminal.*`` config into the env vars terminal tools read.
``tools.terminal_tool`` is intentionally environment-driven because it also
runs in child processes (TUI, dashboard PTY, gateway workers). This helper
gives those child-process launch paths the same config bridge as classic
CLI without importing ``cli.py`` and paying for its startup side effects.
When the user config contains a ``terminal`` section, config.yaml is
authoritative and overrides existing env values. Otherwise defaults only
backfill missing env vars so exported/.env values keep working.
"""
target = os.environ if env is None else env
raw_config = read_raw_config()
file_has_terminal_config = isinstance(raw_config.get("terminal"), dict)
should_override = file_has_terminal_config if override is None else override
cfg = config if config is not None else load_config_readonly()
terminal_cfg = cfg.get("terminal", {}) if isinstance(cfg, dict) else {}
if not isinstance(terminal_cfg, dict):
return target
for cfg_key, env_var in TERMINAL_CONFIG_ENV_MAP.items():
if cfg_key not in terminal_cfg:
continue
value = terminal_cfg[cfg_key]
if cfg_key == "cwd":
raw_cwd = str(value or "").strip()
if raw_cwd in {".", "auto", "cwd"}:
continue
if isinstance(value, str):
value = os.path.expanduser(value)
if should_override or env_var not in target:
target[env_var] = _terminal_env_value(value)
return target
def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
with _CONFIG_LOCK:
ensure_hermes_home()
@@ -6129,9 +6040,36 @@ def set_config_value(key: str, value: str):
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
env_var = terminal_config_env_var_for_key(key)
if env_var and key != "terminal.cwd":
save_env_value(env_var, _terminal_env_value(value))
_config_to_env_sync = {
"terminal.backend": "TERMINAL_ENV",
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"terminal.docker_env": "TERMINAL_DOCKER_ENV",
# JSON-valued keys (terminal_tool parses these via json.loads). The user
# passes JSON on the CLI, so str(value) below already yields valid JSON —
# same as terminal.docker_env. cli.py and gateway/run.py bridge these too.
"terminal.docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"terminal.docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
# terminal.cwd intentionally excluded — CLI resolves at runtime,
# gateway bridges it in gateway/run.py. Persisting to .env causes
# stale values to poison child processes.
"terminal.timeout": "TERMINAL_TIMEOUT",
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
"terminal.container_cpu": "TERMINAL_CONTAINER_CPU",
"terminal.container_memory": "TERMINAL_CONTAINER_MEMORY",
"terminal.container_disk": "TERMINAL_CONTAINER_DISK",
"terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
}
if key in _config_to_env_sync:
save_env_value(_config_to_env_sync[key], str(value))
print(f"✓ Set {key} = {value} in {config_path}")
-31
View File
@@ -1825,11 +1825,6 @@ def _launch_tui(
import tempfile
env = os.environ.copy()
try:
from hermes_cli.config import apply_terminal_config_to_env
apply_terminal_config_to_env(env=env)
except Exception:
logger.debug("Failed to apply terminal config bridge for TUI launch", exc_info=True)
active_session_fd, active_session_file = tempfile.mkstemp(
prefix="hermes-tui-active-session-", suffix=".json"
)
@@ -5811,16 +5806,6 @@ def _update_via_zip(args):
except Exception:
pass
# Seed the model-catalog disk cache from the freshly-unpacked checkout
# (same rationale as the git-pull path in _cmd_update_impl). Non-fatal.
try:
from hermes_cli.model_catalog import seed_cache_from_checkout
if seed_cache_from_checkout(PROJECT_ROOT):
print(" ✓ Model catalog cache refreshed from checkout")
except Exception as e:
logger.debug("Model catalog seed during zip update failed: %s", e)
print()
print("✓ Update complete!")
try:
@@ -8380,22 +8365,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
print()
print("✓ Code updated!")
# Seed the model-catalog disk cache from the freshly-pulled checkout.
# The repo ships the canonical catalog at
# website/static/api/model-catalog.json, and `git pull` just made it
# current — so copy it straight over ~/.hermes/cache/model_catalog.json
# instead of waiting on a network fetch (which can be bot-gated or hit a
# Portal hiccup). Keeps the model picker's curated/free lists in sync
# with the version the user just installed. Non-fatal on failure: the
# normal network refresh still applies on the next picker open.
try:
from hermes_cli.model_catalog import seed_cache_from_checkout
if seed_cache_from_checkout(PROJECT_ROOT):
print(" ✓ Model catalog cache refreshed from checkout")
except Exception as e:
logger.debug("Model catalog seed during update failed: %s", e)
# After git pull, source files on disk are newer than cached Python
# modules in this process. Reload hermes_constants so that any lazy
# import executed below (skills sync, gateway restart) sees new
-31
View File
@@ -356,37 +356,6 @@ def get_curated_nous_models() -> list[str] | None:
return out or None
def seed_cache_from_checkout(project_root: "Path | str") -> bool:
"""Overwrite the disk cache with the catalog shipped in a local checkout.
``hermes update`` pulls the latest repo, so the freshly-pulled
``website/static/api/model-catalog.json`` IS the newest catalog — no
network round-trip needed. Copying it straight over the disk cache keeps
the model picker current even when the remote manifest fetch is bot-gated
or the Portal hiccups.
Reads the shipped manifest, validates it against the schema, and writes it
to ``~/.hermes/cache/model_catalog.json`` via the same atomic writer the
network path uses. Returns ``True`` on success, ``False`` if the file is
missing, malformed, or fails validation (caller should treat a ``False``
as non-fatal — the network fetch path still applies on the next picker
open).
"""
src = Path(project_root) / "website" / "static" / "api" / "model-catalog.json"
try:
with open(src, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
logger.debug("model catalog seed from checkout skipped (%s): %s", src, exc)
return False
if not _validate_manifest(data):
logger.debug("model catalog seed from checkout skipped: invalid manifest at %s", src)
return False
_write_disk_cache(data)
reset_cache() # drop the in-process copy so the next read picks up the seed
return True
def reset_cache() -> None:
"""Clear the in-process cache. Used by tests and ``hermes model --refresh``."""
global _catalog_cache, _catalog_cache_source_mtime
+5 -85
View File
@@ -73,10 +73,8 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
# Free tier
("openrouter/elephant-alpha", "free"),
("openrouter/owl-alpha", "free"),
("poolside/laguna-m.1:free", "free"),
("tencent/hy3-preview:free", "free"),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
("nvidia/nemotron-3-ultra-550b-a55b:free", "free"),
("inclusionai/ring-2.6-1t:free", "free"),
]
@@ -766,64 +764,6 @@ _NOUS_RECOMMENDED_CACHE_TTL: int = 600 # seconds (10 minutes)
_nous_recommended_cache: dict[str, tuple[dict[str, Any], float]] = {}
def _nous_recommended_disk_path() -> "Path":
"""Disk path for the persisted recommended-models cache."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "nous_recommended_cache.json"
def _read_nous_recommended_disk(base: str) -> dict[str, Any] | None:
"""Return the last-known-good payload for ``base`` from disk, or None.
The disk file is a JSON object keyed by portal base URL so staging and
prod don't collide:
``{"<base>": {"data": {...}, "ts": <epoch_seconds>}}``.
"""
try:
with open(_nous_recommended_disk_path(), encoding="utf-8") as fh:
blob = json.load(fh)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(blob, dict):
return None
entry = blob.get(base)
if not isinstance(entry, dict):
return None
data = entry.get("data")
return data if isinstance(data, dict) and data else None
def _write_nous_recommended_disk(base: str, data: dict[str, Any]) -> None:
"""Persist ``data`` as the last-known-good payload for ``base``.
Merges into any existing per-base map, then writes atomically. Failures
are non-fatal (logged at debug) — the in-process cache still works.
"""
if not data:
return
path = _nous_recommended_disk_path()
try:
try:
with open(path, encoding="utf-8") as fh:
blob = json.load(fh)
if not isinstance(blob, dict):
blob = {}
except (OSError, json.JSONDecodeError):
blob = {}
blob[base] = {"data": data, "ts": time.time()}
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(blob, fh, indent=2)
fh.write("\n")
os.replace(tmp, path)
except OSError as exc:
import logging
logging.getLogger(__name__).debug(
"nous recommended-models disk cache write failed: %s", exc
)
def fetch_nous_recommended_models(
portal_base_url: str = "",
timeout: float = 5.0,
@@ -834,19 +774,12 @@ def fetch_nous_recommended_models(
Hits ``<portal>/api/nous/recommended-models``. The endpoint is public —
no auth is required. Results are cached per portal URL for
``_NOUS_RECOMMENDED_CACHE_TTL`` seconds in process; pass
``force_refresh=True`` to bypass the in-process cache.
``_NOUS_RECOMMENDED_CACHE_TTL`` seconds; pass ``force_refresh=True`` to
bypass the cache.
A successful live fetch is also persisted to a per-base disk cache
(``$HERMES_HOME/cache/nous_recommended_cache.json``) as last-known-good.
When the live fetch fails (network, parse, non-2xx) and the in-process
cache is empty, the disk copy is returned instead of ``{}`` — so a
transient Portal hiccup no longer silently drops the free/paid model
recommendations from the picker. Self-heals on the next successful fetch.
Returns the parsed JSON dict, or ``{}`` only when neither the network nor
any cache layer can supply data. Callers must treat missing/null fields
as "no recommendation" and fall back to their own default.
Returns the parsed JSON dict on success, or ``{}`` on any failure
(network, parse, non-2xx). Callers must treat missing/null fields as
"no recommendation" and fall back to their own default.
"""
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
now = time.monotonic()
@@ -869,19 +802,6 @@ def fetch_nous_recommended_models(
except Exception:
data = {}
if data:
# Live fetch succeeded — refresh both cache layers.
_nous_recommended_cache[base] = (data, now)
_write_nous_recommended_disk(base, data)
return data
# Live fetch failed. Fall back to the last-known-good disk copy so a
# transient Portal hiccup doesn't drop the recommendations entirely.
disk = _read_nous_recommended_disk(base)
if disk:
_nous_recommended_cache[base] = (disk, now)
return disk
_nous_recommended_cache[base] = (data, now)
return data
-5
View File
@@ -8573,11 +8573,6 @@ def _resolve_chat_argv(
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
env = os.environ.copy()
try:
from hermes_cli.config import apply_terminal_config_to_env
apply_terminal_config_to_env(env=env)
except Exception:
_log.debug("Failed to apply terminal config bridge for dashboard chat", exc_info=True)
env.setdefault("NODE_ENV", "production")
# Browser-embedded chat should prefer stable wheel-based scrollback over
# native terminal mouse tracking. When mouse tracking is enabled, wheel
-4
View File
@@ -86,7 +86,6 @@ class AudioBridge:
["pactl", "unload-module", str(mod_id)],
check=False,
capture_output=True,
stdin=subprocess.DEVNULL,
)
except Exception:
# Best-effort teardown — never raise from here.
@@ -112,7 +111,6 @@ class AudioBridge:
check=True,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError as exc:
raise RuntimeError(
@@ -137,7 +135,6 @@ class AudioBridge:
check=True,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
except subprocess.CalledProcessError as exc:
# Roll back the null-sink we just created so we don't leak it.
@@ -145,7 +142,6 @@ class AudioBridge:
["pactl", "unload-module", str(sink_mod_id)],
check=False,
capture_output=True,
stdin=subprocess.DEVNULL,
)
raise RuntimeError(
f"pactl load-module virtual-source failed: {exc.stderr or exc}"
-1
View File
@@ -94,7 +94,6 @@ def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT,
result = subprocess.run(
cmd, capture_output=True, text=True,
timeout=timeout, cwd=effective_cwd, env=env,
stdin=subprocess.DEVNULL,
)
stdout = result.stdout.strip()
stderr = result.stderr.strip()
-2
View File
@@ -695,7 +695,6 @@ class HindsightMemoryProvider(MemoryProvider):
subprocess.run(
[uv_path, "pip", "install", "--python", sys.executable, "--quiet", "--upgrade"] + deps_to_install,
check=True, timeout=120, capture_output=True,
stdin=subprocess.DEVNULL,
)
print(" ✓ Dependencies up to date")
except Exception as e:
@@ -1102,7 +1101,6 @@ class HindsightMemoryProvider(MemoryProvider):
[uv_path, "pip", "install", "--python", sys.executable,
"--quiet", "--upgrade", f"hindsight-client>={_MIN_CLIENT_VERSION}"],
check=True, timeout=120, capture_output=True,
stdin=subprocess.DEVNULL,
)
logger.info("hindsight-client upgraded to >=%s", _MIN_CLIENT_VERSION)
except Exception as e:
-1
View File
@@ -416,7 +416,6 @@ def _ensure_sdk_installed() -> bool:
[sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0:
print(" Installed.\n")
-1
View File
@@ -628,7 +628,6 @@ class HonchoClientConfig:
root = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, cwd=cwd, timeout=5,
stdin=subprocess.DEVNULL,
)
if root.returncode == 0:
return Path(root.stdout.strip()).name
-1
View File
@@ -520,7 +520,6 @@ class VoiceReceiver:
],
check=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
finally:
try:
-1
View File
@@ -320,7 +320,6 @@ def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]:
],
capture_output=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
logger.warning("decode_to_pcm failed for %s: %s", path, e)
+7 -8
View File
@@ -111,7 +111,6 @@ All env vars are documented in `plugin.yaml`. The most important:
| `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for the sidecar |
| `PHOTON_SIDECAR_AUTOSTART`| true | Spawn the sidecar on connect |
| `PHOTON_DASHBOARD_HOST` | https://app.photon.codes | Dashboard API host |
| `PHOTON_SPECTRUM_HOST` | https://spectrum.photon.codes | Spectrum API host |
| `PHOTON_HOME_CHANNEL` | your number (set by setup) | Default space for cron delivery — a space id, or a bare E.164 number (resolved to a DM) |
| `PHOTON_ALLOWED_USERS` | your number (set by setup) | Comma-separated E.164 allowlist |
| `PHOTON_REQUIRE_MENTION` | false | Gate group chats on a wake word |
@@ -119,14 +118,14 @@ All env vars are documented in `plugin.yaml`. The most important:
## Attachments & limitations
- **Inbound attachments and voice notes are downloaded.** The sidecar reads
the bytes (`content.read()`) and base64-inlines them on the NDJSON event; the
adapter caches them to the shared media cache and populates `media_urls` /
`media_types`, so the agent sees the real image/file or can transcribe the
voice note — parity with the BlueBubbles iMessage channel. Media larger than
- **Inbound attachments are downloaded.** The sidecar reads the bytes
(`content.read()`) and base64-inlines them on the NDJSON event; the adapter
caches them to the shared media cache and populates `media_urls` /
`media_types`, so the agent sees the real image/file (vision included) —
parity with the BlueBubbles iMessage channel. Attachments larger than
`PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or any byte read that
fails, falls back to a text marker (`[Photon attachment received: …]` or
`[Photon voice received: …]`) so the agent still knows something arrived.
fails, fall back to a text marker (`[Photon attachment received: …]`) so the
agent still knows something arrived.
- **Outbound attachments are supported.** Images, voice notes, video, and
documents are sent via `space.send(attachment(...))` /
`space.send(voice(...))` through the sidecar's `/send-attachment`
+16 -108
View File
@@ -60,7 +60,6 @@ from gateway.platforms.base import (
MessageType,
SendResult,
)
from gateway.platforms.helpers import strip_markdown
from .auth import load_project_credentials
@@ -435,15 +434,13 @@ class PhotonAdapter(BasePlatformAdapter):
"space": {"id": "...", "type": "dm"|"group", "phone": "+E164"},
"sender": {"id": "+E164"},
"content": {"type": "text", "text": "..."}
| {"type": "attachment"|"voice", "id", "name",
"mimeType", "size", "duration"?, "data"?,
"encoding"?},
| {"type": "attachment", "id", "name", "mimeType",
"size", "data"?, "encoding"?},
"timestamp": "2026-05-14T19:06:32.000Z"
Attachment and voice content carry the bytes inline as base64 ``data``
(with ``encoding == "base64"``) when the sidecar could read them
within its size cap; otherwise only metadata is present and we surface
a marker.
Attachment content carries the bytes inline as base64 ``data`` (with
``encoding == "base64"``) when the sidecar could read them within its
size cap; otherwise only metadata is present and we surface a marker.
}
"""
space = event.get("space") or {}
@@ -478,38 +475,23 @@ class PhotonAdapter(BasePlatformAdapter):
if ctype == "text":
text = content.get("text") or ""
mtype = MessageType.TEXT
elif ctype in {"attachment", "voice"}:
is_voice = ctype == "voice"
name = content.get("name") or ("voice" if is_voice else "(unnamed)")
elif ctype == "attachment":
name = content.get("name") or "(unnamed)"
mime = content.get("mimeType") or ""
mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime)
cached = _cache_inbound_attachment(
content, name, mime, force_audio=is_voice
)
mtype = _attachment_message_type(mime)
cached = _cache_inbound_attachment(content, name, mime)
if cached:
media_urls.append(cached)
media_types.append(
mime or ("audio/mp4" if is_voice else "application/octet-stream")
)
media_types.append(mime or "application/octet-stream")
# The real bytes are attached, so the agent sees the media
# itself — a short marker is enough text, and it keeps group
# mention-gating consistent with plain messages.
text = "(voice)" if is_voice else "(attachment)"
text = "(attachment)"
else:
# No bytes (over the sidecar cap, a failed read, or a caching
# failure) — fall back to a metadata marker so the agent still
# knows something arrived.
label = "voice" if is_voice else "attachment"
duration = content.get("duration")
duration_text = (
f", duration: {duration}s"
if isinstance(duration, (int, float))
else ""
)
text = (
f"[Photon {label} received: {name} "
f"({mime or 'unknown MIME'}{duration_text})]"
)
text = f"[Photon attachment received: {name} ({mime})]"
else:
text = f"[Photon content type not handled: {ctype}]"
mtype = MessageType.TEXT
@@ -658,7 +640,7 @@ class PhotonAdapter(BasePlatformAdapter):
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
return await self._sidecar_send(chat_id, self.format_message(content))
return await self._sidecar_send(chat_id, content)
# -- Outbound media (parity with the BlueBubbles iMessage channel) -----
#
@@ -777,74 +759,6 @@ class PhotonAdapter(BasePlatformAdapter):
"""
return {"name": chat_id, "type": "dm", "id": chat_id}
def format_message(self, content: str) -> str:
return strip_markdown(content)
async def _send_with_retry(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Any = None,
max_retries: int = 2,
base_delay: float = 2.0,
) -> SendResult:
"""Photon/iMessage is plain text, so never show the generic Markdown banner."""
text = self.format_message(content)
result = await self.send(
chat_id=chat_id,
content=text,
reply_to=reply_to,
metadata=metadata,
)
if result.success:
return result
error_str = result.error or ""
is_network = result.retryable or self._is_retryable_error(error_str)
if not is_network and self._is_timeout_error(error_str):
return result
if is_network:
for attempt in range(1, max_retries + 1):
delay = base_delay * (2 ** (attempt - 1))
logger.warning(
"[photon] Send failed (attempt %d/%d, retrying in %.1fs): %s",
attempt, max_retries, delay, error_str,
)
await asyncio.sleep(delay)
result = await self.send(
chat_id=chat_id,
content=text,
reply_to=reply_to,
metadata=metadata,
)
if result.success:
return result
error_str = result.error or ""
if not (result.retryable or self._is_retryable_error(error_str)):
break
else:
logger.error(
"[photon] Failed to deliver response after %d retries: %s",
max_retries, error_str,
)
return result
logger.warning(
"[photon] Send failed: %s - retrying plain-text message",
error_str,
)
fallback_result = await self.send(
chat_id=chat_id,
content=text[: self.MAX_MESSAGE_LENGTH],
reply_to=reply_to,
metadata=metadata,
)
if not fallback_result.success:
logger.error("[photon] Plain-text retry also failed: %s", fallback_result.error)
return fallback_result
async def _sidecar_send(self, space_id: str, text: str) -> SendResult:
if len(text) > self.MAX_MESSAGE_LENGTH:
logger.warning(
@@ -967,11 +881,7 @@ _AUDIO_EXT_BY_MIME = {
def _cache_inbound_attachment(
content: Dict[str, Any],
name: str,
mime: str,
*,
force_audio: bool = False,
content: Dict[str, Any], name: str, mime: str
) -> Optional[str]:
"""Decode a base64-inlined inbound attachment and cache it locally.
@@ -1009,10 +919,8 @@ def _cache_inbound_attachment(
# Bytes don't look like a supported image (e.g. HEIC magic) —
# still deliver them as a document rather than dropping them.
return cache_document_from_bytes(raw, name)
if force_audio or mime.startswith("audio/"):
ext = suffix or _AUDIO_EXT_BY_MIME.get(
mime, ".m4a" if force_audio else ".mp3"
)
if mime.startswith("audio/"):
ext = suffix or _AUDIO_EXT_BY_MIME.get(mime, ".mp3")
return cache_audio_from_bytes(raw, ext)
# Video, application/*, and everything else → document cache.
return cache_document_from_bytes(raw, name)
+20 -213
View File
@@ -27,9 +27,8 @@ Credential storage mirrors every other Hermes channel:
* runtime SDK creds -> ``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` =
spectrumProjectId, ``PHOTON_PROJECT_SECRET``) via ``save_env_value``
* management metadata -> ``~/.hermes/auth.json`` under
``credential_pool.photon`` (device token),
``credential_pool.photon_project`` (dashboard id, spectrum id, name), and
``credential_pool.photon_user`` (operator number + assigned text line)
``credential_pool.photon`` (device token) and
``credential_pool.photon_project`` (dashboard id, spectrum id, name)
Reference: https://github.com/photon-hq/cli and
https://photon.codes/docs/api-reference/device-login/request-device-+-user-code
@@ -41,7 +40,6 @@ import logging
import os
import re
import time
from base64 import b64encode
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
@@ -69,7 +67,6 @@ DEFAULT_CLIENT_ID = "photon-cli"
DEFAULT_SCOPE = "openid profile email"
DEFAULT_DASHBOARD_HOST = "https://app.photon.codes"
DEFAULT_SPECTRUM_HOST = "https://spectrum.photon.codes"
# Default name of the project Hermes provisions for the operator.
DEFAULT_PROJECT_NAME = "Hermes Agent"
@@ -208,30 +205,6 @@ def store_project_credentials(
_persist_runtime_env(spectrum_project_id, project_secret)
def store_user_numbers(
*,
phone_number: Optional[str] = None,
assigned_phone_number: Optional[str] = None,
user_id: Optional[str] = None,
dashboard_project_id: Optional[str] = None,
) -> None:
"""Persist non-secret Photon user numbers for offline ``status`` output."""
if not phone_number and not assigned_phone_number:
return
auth = _load_auth()
record: Dict[str, Any] = {"issued_at": int(time.time())}
if phone_number:
record["phone_number"] = phone_number
if assigned_phone_number:
record["assigned_phone_number"] = assigned_phone_number
if user_id:
record["user_id"] = user_id
if dashboard_project_id:
record["dashboard_project_id"] = dashboard_project_id
auth.setdefault("credential_pool", {})["photon_user"] = [record]
_save_auth(auth)
def _persist_runtime_env(spectrum_project_id: str, project_secret: str) -> None:
"""Write the SDK creds to ``~/.hermes/.env`` (canonical runtime store).
@@ -275,43 +248,10 @@ def _dashboard_host() -> str:
return (os.getenv("PHOTON_DASHBOARD_HOST") or DEFAULT_DASHBOARD_HOST).rstrip("/")
def _spectrum_host() -> str:
return (os.getenv("PHOTON_SPECTRUM_HOST") or DEFAULT_SPECTRUM_HOST).rstrip("/")
def _bearer(token: str) -> Dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _basic(project_id: str, project_secret: str) -> Dict[str, str]:
token = b64encode(f"{project_id}:{project_secret}".encode("utf-8")).decode("ascii")
return {"Authorization": f"Basic {token}"}
def _response_error_detail(resp: Any) -> str:
try:
data = resp.json()
except Exception:
data = None
if isinstance(data, dict):
for key in ("error", "message", "detail"):
val = data.get(key)
if val:
return str(val)
return json.dumps(data, sort_keys=True)[:500]
text = getattr(resp, "text", "") or ""
return text[:500] if text else "no response body"
def _raise_for_status(resp: Any, action: str) -> None:
status = getattr(resp, "status_code", 200)
if status < 400:
return
raise RuntimeError(
f"Photon {action} failed: HTTP {status}: {_response_error_detail(resp)}"
)
def request_device_code(
*, client_id: str = DEFAULT_CLIENT_ID, scope: Optional[str] = DEFAULT_SCOPE,
) -> DeviceCode:
@@ -619,11 +559,6 @@ def _unwrap_list(data: Any) -> List[Dict[str, Any]]:
inner = data.get(key)
if isinstance(inner, list):
return inner
if isinstance(inner, dict):
for nested_key in ("projects", "users", "lines", "items"):
nested = inner.get(nested_key)
if isinstance(nested, list):
return nested
return []
@@ -727,37 +662,37 @@ def regenerate_project_secret(token: str, project_id: str) -> str:
# ---------------------------------------------------------------------------
# Spectrum API: users
# Dashboard API: spectrum users
def _normalize_phone(phone: str) -> str:
"""Reduce a phone string to ``+`` and digits for dedup comparison."""
return re.sub(r"[^\d+]", "", phone or "")
def list_users(project_id: str, project_secret: str) -> List[Dict[str, Any]]:
"""GET Spectrum Cloud ``/projects/{id}/users/`` → ``SpectrumUser[]``."""
def list_users(token: str, project_id: str) -> List[Dict[str, Any]]:
"""GET ``/api/projects/{id}/spectrum/users`` → ``SpectrumUser[]``."""
if httpx is None:
raise RuntimeError("httpx is required for Photon")
url = f"{_spectrum_host()}/projects/{project_id}/users/"
resp = httpx.get(url, headers=_basic(project_id, project_secret), timeout=30.0)
_raise_for_status(resp, "list-users")
url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/users"
resp = httpx.get(url, headers=_bearer(token), timeout=30.0)
resp.raise_for_status()
return _unwrap_list(resp.json())
def find_user_by_phone(
project_id: str, project_secret: str, phone_number: str,
token: str, project_id: str, phone_number: str,
) -> Optional[Dict[str, Any]]:
"""Return an existing Spectrum user with the given phone number, or None."""
target = _normalize_phone(phone_number)
for user in list_users(project_id, project_secret):
for user in list_users(token, project_id):
if _normalize_phone(user.get("phoneNumber") or "") == target:
return user
return None
def create_user(
token: str,
project_id: str,
project_secret: str,
*,
phone_number: str,
first_name: Optional[str] = None,
@@ -765,42 +700,32 @@ def create_user(
email: Optional[str] = None,
send_invite: bool = False,
) -> Dict[str, Any]:
"""POST Spectrum Cloud ``/projects/{id}/users/`` and return the user."""
"""POST ``/api/projects/{id}/spectrum/users`` and return the created user."""
if httpx is None:
raise RuntimeError("httpx is required for Photon user creation")
if not E164_RE.match(phone_number):
raise ValueError(
f"phone_number must be E.164 (e.g. +15551234567); got {phone_number!r}"
)
url = f"{_spectrum_host()}/projects/{project_id}/users/"
body: Dict[str, Any] = {"type": "shared", "phoneNumber": phone_number}
if send_invite:
logger.debug("photon: send_invite is ignored by Spectrum shared-user creation")
url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/users"
body: Dict[str, Any] = {"phoneNumber": phone_number, "sendInvite": send_invite}
if first_name:
body["firstName"] = first_name
if last_name:
body["lastName"] = last_name
if email:
body["email"] = email
resp = httpx.post(
url,
json=body,
headers=_basic(project_id, project_secret),
timeout=30.0,
)
_raise_for_status(resp, "create-user")
resp = httpx.post(url, json=body, headers=_bearer(token), timeout=30.0)
resp.raise_for_status()
data = resp.json() or {}
if data.get("error"):
raise RuntimeError(f"Photon create-user failed: {data['error']}")
user = data.get("user") or data.get("data") or data
if isinstance(user, dict):
return user
raise RuntimeError("Photon create-user returned an unexpected response")
return data.get("user") or data
def register_user_if_absent(
token: str,
project_id: str,
project_secret: str,
*,
phone_number: str,
first_name: Optional[str] = None,
@@ -813,12 +738,11 @@ def register_user_if_absent(
same phone number already exists (the official CLI does no dedup, so we
add it here to make ``setup`` safely re-runnable).
"""
existing = find_user_by_phone(project_id, project_secret, phone_number)
existing = find_user_by_phone(token, project_id, phone_number)
if existing is not None:
return existing, False
user = create_user(
project_id,
project_secret,
token, project_id,
phone_number=phone_number,
first_name=first_name,
last_name=last_name,
@@ -842,104 +766,6 @@ def user_assigned_line(user: Optional[Dict[str, Any]]) -> Optional[str]:
return str(val) if val else None
def load_user_numbers() -> Tuple[Optional[str], Optional[str]]:
"""Return ``(operator_phone_number, assigned_phone_number)`` for status."""
auth = _load_auth()
user_entries = auth.get("credential_pool", {}).get("photon_user") or []
if isinstance(user_entries, list) and user_entries:
entry = user_entries[0] or {}
if isinstance(entry, dict):
phone = entry.get("phone_number") or entry.get("phoneNumber")
assigned = (
entry.get("assigned_phone_number")
or entry.get("assignedPhoneNumber")
)
if phone or assigned:
return (
str(phone) if phone else _configured_operator_phone(),
str(assigned) if assigned else None,
)
return _configured_operator_phone(), None
def refresh_user_numbers(
project_id: str, project_secret: str,
) -> Tuple[Optional[str], Optional[str]]:
"""Refresh cached user numbers from Photon without provisioning anything."""
phone, cached_assigned = load_user_numbers()
user: Optional[Dict[str, Any]] = None
if phone:
user = find_user_by_phone(project_id, project_secret, phone)
else:
users = list_users(project_id, project_secret)
if len(users) == 1:
user = users[0]
user_id = None
assigned: Optional[str] = cached_assigned
if user:
user_id = user.get("id")
dashboard_phone = _normalize_phone(str(user.get("phoneNumber") or ""))
if E164_RE.match(dashboard_phone):
phone = dashboard_phone
assigned = user_assigned_line(user)
dashboard_id = load_dashboard_project_id()
if not assigned:
dashboard_token = load_photon_token()
if dashboard_token and dashboard_id:
try:
line = get_imessage_line(
dashboard_token,
dashboard_id,
create_if_missing=False,
)
except Exception as e:
logger.debug(
"photon: could not refresh iMessage line for status: %s", e
)
else:
if line and line.get("phoneNumber"):
assigned = str(line["phoneNumber"])
store_user_numbers(
phone_number=phone,
assigned_phone_number=assigned,
user_id=str(user_id) if user_id else None,
dashboard_project_id=dashboard_id,
)
return phone, assigned
def _configured_operator_phone() -> Optional[str]:
"""Infer the operator's E.164 number from existing Photon env settings."""
home = _get_config_env_value("PHOTON_HOME_CHANNEL")
if home:
normalized = _normalize_phone(home)
if E164_RE.match(normalized):
return normalized
allowed = _get_config_env_value("PHOTON_ALLOWED_USERS")
if not allowed:
return None
candidates = []
for part in re.split(r"[,\s]+", allowed):
normalized = _normalize_phone(part)
if E164_RE.match(normalized):
candidates.append(normalized)
if len(candidates) == 1:
return candidates[0]
return None
def _get_config_env_value(key: str) -> Optional[str]:
try:
from hermes_cli.config import get_env_value
except Exception:
return os.getenv(key)
return get_env_value(key)
# ---------------------------------------------------------------------------
# Dashboard API: iMessage lines (the assigned number inventory)
@@ -1010,13 +836,6 @@ def print_credential_summary(emit: Any = print) -> None:
labels["spectrum_project_id"] = sid if sid else "✗ missing"
labels["dashboard_project_id"] = load_dashboard_project_id() or ""
labels["project_key"] = "✓ stored" if sec else "✗ missing"
phone, assigned = load_user_numbers()
labels["phone_number"] = (
phone if phone else "✗ missing (run `hermes photon setup --phone ...`)"
)
labels["assigned_phone_number"] = (
assigned if assigned else "✗ missing (run `hermes photon setup`)"
)
rows = [
"Photon iMessage status",
@@ -1025,8 +844,6 @@ def print_credential_summary(emit: Any = print) -> None:
" dashboard project : " + labels["dashboard_project_id"],
" spectrum project id : " + labels["spectrum_project_id"],
" project secret : " + labels["project_key"],
" my number : " + labels["phone_number"],
" assigned number : " + labels["assigned_phone_number"],
]
emit("\n".join(rows))
@@ -1047,19 +864,9 @@ def credential_summary() -> Dict[str, str]:
_sid, sec = load_project_credentials()
return "✓ stored" if sec else "✗ missing"
def _present_phone() -> str:
phone, _assigned = load_user_numbers()
return phone or "✗ missing (run `hermes photon setup --phone ...`)"
def _present_assigned_phone() -> str:
_phone, assigned = load_user_numbers()
return assigned or "✗ missing (run `hermes photon setup`)"
return {
"device_token": _present_token(),
"dashboard_project_id": load_dashboard_project_id() or "",
"spectrum_project_id": _present_spectrum_id(),
"project_key": _present_secret(),
"phone_number": _present_phone(),
"assigned_phone_number": _present_assigned_phone(),
}
+3 -35
View File
@@ -183,8 +183,6 @@ def _cmd_setup(args: argparse.Namespace) -> int:
)
)
agent_number = None
registered_phone = None
registered_user_id = None
if not phone:
print(" Skipped user registration (no phone given). Re-run with --phone later.")
else:
@@ -194,7 +192,7 @@ def _cmd_setup(args: argparse.Namespace) -> int:
email = args.email
try:
user, created = photon_auth.register_user_if_absent(
spectrum_id, secret,
token, dashboard_id,
phone_number=phone,
first_name=first_name,
last_name=args.last_name,
@@ -207,8 +205,6 @@ def _cmd_setup(args: argparse.Namespace) -> int:
print(f" user registration failed: {e}", file=sys.stderr)
return 1
print(" ✓ phone registered" if created else " ✓ phone already registered")
registered_phone = phone
registered_user_id = user.get("id")
# The number to text the agent is the user's assigned iMessage line
# (the dashboard's "TEXTS ON" column). On shared-number plans there is
# no dedicated entry in /lines, so this per-user field is the source of
@@ -240,16 +236,6 @@ def _cmd_setup(args: argparse.Namespace) -> int:
print(color("└──────────────────────────────────────────────────────────────", Colors.GREEN))
else:
print(" No iMessage line assigned yet — check the Photon dashboard.")
if registered_phone:
try:
photon_auth.store_user_numbers(
phone_number=registered_phone,
assigned_phone_number=agent_number,
user_id=str(registered_user_id) if registered_user_id else None,
dashboard_project_id=dashboard_id,
)
except Exception as e:
print(f" (could not save Photon status metadata: {e})", file=sys.stderr)
# 6. Sidecar deps (spectrum-ts).
if args.skip_sidecar_install:
@@ -294,7 +280,6 @@ def _autoconfigure_access(phone: str) -> None:
def _cmd_status(_args: argparse.Namespace) -> int:
_refresh_status_numbers()
# Defer the credential rows to auth.print_credential_summary — its emit
# callback is the only sink that sees credential-derived strings, so
# cli.py keeps zero taint flow according to CodeQL.
@@ -306,19 +291,6 @@ def _cmd_status(_args: argparse.Namespace) -> int:
return 0
def _refresh_status_numbers() -> None:
phone, assigned = photon_auth.load_user_numbers()
if phone and assigned:
return
spectrum_id, project_secret = photon_auth.load_project_credentials()
if not spectrum_id or not project_secret:
return
try:
photon_auth.refresh_user_numbers(spectrum_id, project_secret)
except Exception as e:
print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr)
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
return _install_sidecar()
@@ -332,13 +304,9 @@ def _install_sidecar() -> int:
file=sys.stderr,
)
return 1
# Always pull the newest published spectrum-ts so every setup runs against
# the latest SDK. `spectrum-ts@latest` bumps package.json + package-lock.json
# to the current release before installing — a plain `npm install` would
# stay pinned to whatever the committed lockfile already resolved.
print(f" $ cd {_SIDECAR_DIR} && {npm} install spectrum-ts@latest")
print(f" $ cd {_SIDECAR_DIR} && {npm} install")
proc = subprocess.run( # noqa: S603
[npm, "install", "spectrum-ts@latest"],
[npm, "install"],
cwd=str(_SIDECAR_DIR),
check=False,
)
-4
View File
@@ -46,10 +46,6 @@ optional_env:
description: "Photon Dashboard API host (default https://app.photon.codes)"
prompt: "Dashboard host"
password: false
- name: PHOTON_SPECTRUM_HOST
description: "Photon Spectrum API host (default https://spectrum.photon.codes)"
prompt: "Spectrum API host"
password: false
- name: PHOTON_ALLOWED_USERS
description: "Comma-separated E.164 phone numbers allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
+49 -58
View File
@@ -48,11 +48,11 @@ const port = parseInt(process.env.PHOTON_SIDECAR_PORT || "8789", 10);
const bind = process.env.PHOTON_SIDECAR_BIND || "127.0.0.1";
const sharedToken = process.env.PHOTON_SIDECAR_TOKEN;
// Inbound binary content is read into memory and base64-inlined on the NDJSON
// Inbound attachments are read into memory and base64-inlined on the NDJSON
// event so the Python adapter can cache the real bytes (and the agent can see
// images / transcribe voice). Cap the size we inline — above it we forward
// metadata only and the adapter surfaces a text marker, so one large clip can't
// balloon a single NDJSON line. Override via PHOTON_MAX_INLINE_ATTACHMENT_BYTES.
// the image). Cap the size we inline — above it we forward metadata only and
// the adapter surfaces a text marker, so one large video can't balloon a
// single NDJSON line. Override via PHOTON_MAX_INLINE_ATTACHMENT_BYTES.
const MAX_INLINE_ATTACHMENT_BYTES =
Number(process.env.PHOTON_MAX_INLINE_ATTACHMENT_BYTES) || 20 * 1024 * 1024;
const DM_CHAT_GUID_RE = /^any;-;(\+\d{6,})$/;
@@ -92,7 +92,6 @@ const app = await Spectrum({
projectId,
projectSecret,
providers: [imessage.config()],
options: { flattenGroups: true },
});
// ---------------------------------------------------------------------------
@@ -164,57 +163,6 @@ async function deliver(line) {
}
}
async function normalizeBinaryContent(content) {
const meta = {
type: content.type,
id: content.id ?? null,
name: content.name ?? null,
mimeType: content.mimeType ?? null,
size: typeof content.size === "number" ? content.size : null,
};
if (content.type === "voice" && typeof content.duration === "number") {
meta.duration = content.duration;
}
// Read the bytes eagerly and base64-inline them as `data` so the Python
// adapter can cache the real file (the agent then sees images and can run
// STT on voice notes). Spectrum content objects may not outlive this stream
// iteration, so a lazy/on-demand fetch isn't safe. Over-cap content (when
// size is known up front) is forwarded as metadata only and the adapter falls
// back to a text marker. A read failure must never break the inbound loop.
const label = `${content.type} ${meta.name ?? meta.id ?? "(unnamed)"}`;
if (meta.size !== null && meta.size > MAX_INLINE_ATTACHMENT_BYTES) {
console.error(
`photon-sidecar: ${label} (${meta.size} bytes) ` +
`exceeds inline cap ${MAX_INLINE_ATTACHMENT_BYTES}; forwarding metadata only`
);
return meta;
}
if (typeof content.read === "function") {
try {
const buf = await content.read();
// Guard the case where size was unknown but the bytes turn out to be
// over the cap.
if (buf && buf.length > MAX_INLINE_ATTACHMENT_BYTES) {
console.error(
`photon-sidecar: ${label} (${buf.length} bytes) ` +
`exceeds inline cap after read; forwarding metadata only`
);
return meta;
}
meta.data = Buffer.from(buf).toString("base64");
meta.encoding = "base64";
} catch (e) {
console.error(
`photon-sidecar: failed to read ${content.type} bytes ` +
"(forwarding metadata only): " +
(e && e.stack ? e.stack : String(e))
);
}
}
return meta;
}
async function normalizeContent(content) {
if (!content || typeof content !== "object") {
return { type: "unknown" };
@@ -222,8 +170,51 @@ async function normalizeContent(content) {
if (content.type === "text") {
return { type: "text", text: content.text || "" };
}
if (content.type === "attachment" || content.type === "voice") {
return await normalizeBinaryContent(content);
if (content.type === "attachment") {
const meta = {
type: "attachment",
id: content.id ?? null,
name: content.name ?? null,
mimeType: content.mimeType ?? null,
size: typeof content.size === "number" ? content.size : null,
};
// Read the bytes eagerly and base64-inline them as `data` so the Python
// adapter can cache the real file (the agent then sees the image itself).
// The spectrum-ts attachment object may not outlive this stream
// iteration, so a lazy/on-demand fetch isn't safe. Over-cap attachments
// (when size is known up front) are forwarded as metadata only and the
// adapter falls back to a text marker. A read failure must never break
// the inbound loop — we just drop `data` and forward metadata.
if (meta.size !== null && meta.size > MAX_INLINE_ATTACHMENT_BYTES) {
console.error(
`photon-sidecar: attachment ${meta.name ?? meta.id} (${meta.size} bytes) ` +
`exceeds inline cap ${MAX_INLINE_ATTACHMENT_BYTES}; forwarding metadata only`
);
return meta;
}
if (typeof content.read === "function") {
try {
const buf = await content.read();
// Guard the case where size was unknown but the bytes turn out to be
// over the cap.
if (buf && buf.length > MAX_INLINE_ATTACHMENT_BYTES) {
console.error(
`photon-sidecar: attachment ${meta.name ?? meta.id} (${buf.length} bytes) ` +
`exceeds inline cap after read; forwarding metadata only`
);
return meta;
}
meta.data = Buffer.from(buf).toString("base64");
meta.encoding = "base64";
} catch (e) {
console.error(
"photon-sidecar: failed to read attachment bytes " +
"(forwarding metadata only): " +
(e && e.stack ? e.stack : String(e))
);
}
}
return meta;
}
return { type: content.type || "unknown" };
}
+1 -1
View File
@@ -8,7 +8,7 @@
"name": "@hermes-agent/photon-sidecar",
"version": "0.2.0",
"dependencies": {
"spectrum-ts": "^1.18.0"
"spectrum-ts": "^1.17.1"
},
"engines": {
"node": ">=18.17"
@@ -12,6 +12,6 @@
"node": ">=18.17"
},
"dependencies": {
"spectrum-ts": "^1.18.0"
"spectrum-ts": "^1.17.1"
}
}
-177
View File
@@ -1,177 +0,0 @@
#!/usr/bin/env python3
"""Check that subprocess calls in TUI-context code specify stdin=.
When Hermes runs in TUI mode, the gateway child process communicates with
the Node.js parent over a JSON-RPC protocol on stdin. Subprocess calls that
inherit this fd can cause the gateway to exit with stdin EOF during tool
execution (issue #14036, PR #39257).
This script checks that all subprocess.run() and subprocess.Popen() calls
in TUI-context files (agent/, tools/, plugins/, tui_gateway/) explicitly
set stdin= to prevent fd inheritance.
Exit codes:
0 all calls are safe
1 violations found
2 script error
Usage:
python scripts/check_subprocess_stdin.py [--fix]
With --fix, prints the commands to add stdin=subprocess.DEVNULL to each
violation (does not modify files).
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
# Directories that run inside the TUI gateway child process.
TUI_CONTEXT_DIRS = [
"agent/",
"tools/",
"plugins/",
"tui_gateway/",
]
# Files with intentional stdin= override (e.g. input= creates a pipe).
# Format: "filepath:line" or just "filepath" to skip the whole file.
KNOWN_SAFE = {
"agent/shell_hooks.py", # uses input=stdin_json, creates a pipe
"plugins/security-guidance/patterns.py", # subprocess mentions are in reminder strings, not calls
}
# Inline marker that exempts a single subprocess call from this check.
# Put it in a comment on (or within) the call when the process MUST inherit
# stdin — e.g. an interactive login the user explicitly invokes. Travels with
# the line, so it survives edits that shift line numbers (unlike a pinned
# file:line entry).
EXEMPT_MARKER = "noqa: subprocess-stdin"
# Directories to skip entirely.
SKIP_DIRS = {
"tests/",
"scripts/",
"skills/",
"optional-skills/",
"hermes_cli/",
"gateway/",
"cron/",
}
def find_subprocess_calls(content: str, filepath: str) -> list[dict]:
"""Find all subprocess.run/Popen calls missing stdin= in content."""
violations = []
lines = content.split("\n")
# Match only actual function calls — not comments, docstrings, or prose.
# The pattern requires an opening paren followed by an arg character
# (quote, bracket, letter, or closing paren for empty calls).
# This excludes ``subprocess.Popen(...)`` in docstrings and
# subprocess.run(...) in comments.
pattern = re.compile(r'subprocess\.(run|Popen)\s*\(["\'a-zA-Z_\[\(]')
for i, line in enumerate(lines):
# Skip comments.
stripped = line.lstrip()
if stripped.startswith("#"):
continue
# Skip lines where the match is inside backticks (docstring references).
if "``subprocess" in line:
continue
if not pattern.search(line):
continue
# Collect the full call (may span multiple lines).
call_start = i
paren_depth = 0
found_open = False
call_lines = []
for j in range(i, min(i + 30, len(lines))):
call_lines.append(lines[j])
for ch in lines[j]:
if ch == "(":
paren_depth += 1
found_open = True
elif ch == ")":
paren_depth -= 1
if found_open and paren_depth == 0:
call_text = "\n".join(call_lines)
# Already has stdin= → safe.
if "stdin=" in call_text:
break
# Has input= → creates a pipe, safe.
if "input=" in call_text:
break
# Inline exemption marker on the call itself or within
# the few comment lines immediately above it → the call
# intentionally inherits stdin.
window_start = max(0, i - 4)
preceding = "\n".join(lines[window_start:i])
if EXEMPT_MARKER in call_text or EXEMPT_MARKER in preceding:
break
violations.append({
"file": filepath,
"line": i + 1,
"snippet": line.strip()[:120],
})
break
else:
continue
break
return violations
def main() -> int:
fix_mode = "--fix" in sys.argv
repo_root = Path(__file__).resolve().parent.parent
os.chdir(repo_root)
all_violations = []
for tui_dir in TUI_CONTEXT_DIRS:
dirpath = repo_root / tui_dir
if not dirpath.exists():
continue
for py_file in dirpath.rglob("*.py"):
rel = str(py_file.relative_to(repo_root))
# Skip known-safe files.
if rel in KNOWN_SAFE:
continue
# Skip test files inside tools/ etc.
parts = py_file.parts
if any(skip.rstrip("/") in parts for skip in SKIP_DIRS):
continue
content = py_file.read_text()
violations = find_subprocess_calls(content, rel)
all_violations.extend(violations)
if all_violations:
print(f"{len(all_violations)} subprocess calls missing stdin=:")
for v in all_violations:
print(f" {v['file']}:{v['line']}: {v['snippet']}")
if fix_mode:
print("\nAdd stdin=subprocess.DEVNULL to each call above.")
return 1
else:
print("✅ All TUI-context subprocess calls have explicit stdin=")
return 0
if __name__ == "__main__":
sys.exit(main())
-2
View File
@@ -58,14 +58,12 @@ AUTHOR_MAP = {
"thomas.paquette@gmail.com": "RyTsYdUp",
"techxacm@gmail.com": "ProgramCaiCai",
"266365592+bmoore210@users.noreply.github.com": "bmoore210",
"157839748+psionic73@users.noreply.github.com": "psionic73",
"manishbyatroy@gmail.com": "manishbyatroy",
"chilltulpa@gmail.com": "TheGardenGallery",
"al@randomsnowflake.me": "randomsnowflake",
"zakame@zakame.net": "zakame",
"152110621+jiangkoumo@users.noreply.github.com": "jiangkoumo",
"834740219@qq.com": "ViewWay",
"matt@vestigial.dev": "m4dni5",
"harjoth.khara@gmail.com": "harjothkhara",
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
+1 -100
View File
@@ -159,106 +159,7 @@ caption
tags, voice = _collect_auto_append_media_tags(messages, history_offset=0)
assert tags == ["MEDIA:/tmp/voice.ogg"]
assert voice is True
def test_gateway_auto_append_image_generate_json_path(self):
"""image_generate returns a local path in JSON (no MEDIA: tag); it is
auto-appended so delivery doesn't depend on the model restating it."""
from gateway.run import _collect_auto_append_media_tags
messages = [
{"role": "user", "content": "Make me a cat"},
{
"role": "assistant",
"tool_calls": [
{"id": "call_img", "function": {"name": "image_generate"}}
],
},
{
"role": "tool",
"tool_call_id": "call_img",
"content": '{"success": true, "image": "/tmp/gen/cat.png", "agent_visible_image": "/tmp/gen/cat.png"}',
},
{"role": "assistant", "content": "Here's your cat."},
]
tags, voice = _collect_auto_append_media_tags(messages, history_offset=0)
assert tags == ["MEDIA:/tmp/gen/cat.png"]
assert voice is False
def test_gateway_auto_append_image_generate_prefers_host_path(self):
"""When host and sandbox paths differ, the host-deliverable path wins."""
from gateway.run import _collect_auto_append_media_tags
messages = [
{"role": "user", "content": "Make me a dog"},
{
"role": "assistant",
"tool_calls": [
{"id": "call_img", "function": {"name": "image_generate"}}
],
},
{
"role": "tool",
"tool_call_id": "call_img",
"content": '{"success": true, "host_image": "/host/dog.jpg", "image": "/host/dog.jpg", "agent_visible_image": "/sandbox/dog.jpg"}',
},
]
tags, _ = _collect_auto_append_media_tags(messages, history_offset=0)
assert tags == ["MEDIA:/host/dog.jpg"]
def test_gateway_auto_append_image_generate_failure_and_url_ignored(self):
"""Failed generations and remote URLs are not auto-delivered."""
from gateway.run import _collect_auto_append_media_tags
def _img_msgs(content):
return [
{
"role": "assistant",
"tool_calls": [
{"id": "c", "function": {"name": "image_generate"}}
],
},
{"role": "tool", "tool_call_id": "c", "content": content},
]
# Failed generation
tags, _ = _collect_auto_append_media_tags(
_img_msgs('{"success": false, "image": null, "error": "boom"}'),
history_offset=0,
)
assert tags == []
# Remote URL is not a local file path
tags, _ = _collect_auto_append_media_tags(
_img_msgs('{"success": true, "image": "https://fal.media/x/cat.png"}'),
history_offset=0,
)
assert tags == []
def test_gateway_auto_append_image_generate_dedupes_history(self):
"""A generated image path already in history is not re-sent."""
from gateway.run import _collect_auto_append_media_tags
messages = [
{
"role": "assistant",
"tool_calls": [
{"id": "c", "function": {"name": "image_generate"}}
],
},
{
"role": "tool",
"tool_call_id": "c",
"content": '{"success": true, "image": "/tmp/gen/cat.png"}',
},
]
tags, _ = _collect_auto_append_media_tags(
messages, history_offset=0, history_media_paths={"/tmp/gen/cat.png"}
)
assert tags == []
def test_media_tags_not_extracted_from_history(self):
"""MEDIA tags from previous turns should NOT be extracted again."""
# Simulate conversation history with a TTS call from a previous turn
+9 -11
View File
@@ -1295,10 +1295,10 @@ class TerminalCommandAgent:
@pytest.mark.asyncio
async def test_terminal_progress_renders_fenced_code_block(monkeypatch, tmp_path):
"""Terminal progress on a markdown-capable (supports_code_blocks) gateway
renders the full command in a bare fenced code block no language tag
(Slack mrkdwn would print 'bash' as a literal first code line)."""
async def test_terminal_progress_is_truncated_preview_not_bash_block(monkeypatch, tmp_path):
"""Regression for #41215: terminal progress must render as a short truncated
preview, never the full command in a fenced ```bash block, even on a
markdown-capable (supports_code_blocks) gateway."""
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
fake_dotenv = types.ModuleType("dotenv")
@@ -1328,20 +1328,18 @@ async def test_terminal_progress_renders_fenced_code_block(monkeypatch, tmp_path
context_prompt="",
history=[],
source=source,
session_id="sess-terminal-code-block",
session_id="sess-terminal-no-bash-block",
session_key="agent:main:telegram:dm:12345",
)
assert result["final_response"] == "done"
all_content = " ".join(call["content"] for call in adapter.sent)
all_content += " ".join(call["content"] for call in adapter.edits)
# Bare fenced block, no language tag (no '```bash').
assert "```" in all_content
# Compact truncated preview, not a fenced bash block.
assert "```bash" not in all_content
# The full multi-line command body IS present in the block.
assert "npm install -g hyperframes@latest" in all_content
# No truncated quoted preview for the terminal command.
assert 'terminal: "' not in all_content
assert 'terminal: "' in all_content
# The full multi-line command body must not reach the chat.
assert "npm install -g hyperframes@latest" not in all_content
@pytest.mark.asyncio
+1 -11
View File
@@ -41,16 +41,6 @@ def _suppress_concurrent_hermes_gate(request, monkeypatch):
from hermes_cli import main as _cli_main
except Exception:
return
# raising=False: under pytest's per-test spawn isolation, a concurrent
# xdist worker importing a module that transitively touches hermes_cli.main
# can briefly expose a partially-initialized module object here — one where
# _detect_concurrent_hermes_instances isn't defined yet. A bare setattr
# would raise AttributeError and error the (unrelated) test. The attribute
# always exists once main.py finishes importing, so a no-op when it's
# transiently absent is the correct, race-free default.
monkeypatch.setattr(
_cli_main,
"_detect_concurrent_hermes_instances",
lambda *_a, **_k: [],
raising=False,
_cli_main, "_detect_concurrent_hermes_instances", lambda *_a, **_k: []
)
-40
View File
@@ -896,46 +896,6 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
assert env["NODE_ENV"] == "production"
def test_launch_tui_applies_terminal_backend_config(
monkeypatch, main_mod, _isolate_hermes_home
):
captured = {}
config_path = Path(os.environ["HERMES_HOME"]) / "config.yaml"
config_path.write_text(
"\n".join(
[
"terminal:",
" backend: docker",
" docker_image: example/hermes-tools:latest",
" docker_extra_args:",
" - --network=host",
]
),
encoding="utf-8",
)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_IMAGE", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_EXTRA_ARGS", raising=False)
monkeypatch.setattr(
main_mod,
"_make_tui_argv",
lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")),
)
monkeypatch.setattr(
main_mod.subprocess,
"call",
lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1,
)
with pytest.raises(SystemExit):
main_mod._launch_tui()
assert captured["env"]["TERMINAL_ENV"] == "docker"
assert captured["env"]["TERMINAL_DOCKER_IMAGE"] == "example/hermes-tools:latest"
assert captured["env"]["TERMINAL_DOCKER_EXTRA_ARGS"] == '["--network=host"]'
def test_launch_tui_exit_code_42_relaunches_update(monkeypatch, main_mod):
from unittest.mock import patch
-33
View File
@@ -4146,39 +4146,6 @@ class TestPtyWebSocket:
assert env["HERMES_TUI_INLINE"] == "1"
assert env["HERMES_TUI_DISABLE_MOUSE"] == "1"
def test_resolve_chat_argv_applies_terminal_backend_config(
self, monkeypatch, _isolate_hermes_home
):
import hermes_cli.main as main_mod
config_path = Path(os.environ["HERMES_HOME"]) / "config.yaml"
config_path.write_text(
"\n".join(
[
"terminal:",
" backend: docker",
" docker_image: example/hermes-tools:latest",
" docker_extra_args:",
" - --network=host",
]
),
encoding="utf-8",
)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_IMAGE", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_EXTRA_ARGS", raising=False)
monkeypatch.setattr(
main_mod,
"_make_tui_argv",
lambda project_root, tui_dev=False: (["node", "dist/entry.js"], "/tmp/ui-tui"),
)
_argv, _cwd, env = self.ws_module._resolve_chat_argv()
assert env["TERMINAL_ENV"] == "docker"
assert env["TERMINAL_DOCKER_IMAGE"] == "example/hermes-tools:latest"
assert env["TERMINAL_DOCKER_EXTRA_ARGS"] == '["--network=host"]'
def test_rejects_when_embedded_chat_disabled(self, monkeypatch):
monkeypatch.setattr(self.ws_module, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", False)
from starlette.websockets import WebSocketDisconnect
+11 -78
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import json
import os
from base64 import b64encode
from pathlib import Path
from typing import Any, Dict
@@ -41,9 +40,6 @@ _PHOTON_ENV = (
"PHOTON_PROJECT_ID",
"PHOTON_PROJECT_SECRET",
"PHOTON_DASHBOARD_PROJECT_ID",
"PHOTON_SPECTRUM_HOST",
"PHOTON_ALLOWED_USERS",
"PHOTON_HOME_CHANNEL",
)
@@ -102,64 +98,6 @@ def test_store_project_credentials_writes_env(tmp_hermes_home: Path) -> None:
assert "PHOTON_PROJECT_SECRET=sek-ret" in env_text
def test_store_user_numbers_round_trip(tmp_hermes_home: Path) -> None:
photon_auth.store_user_numbers(
phone_number="+15551234567",
assigned_phone_number="+16282679185",
user_id="user-uuid",
dashboard_project_id="dash-uuid",
)
phone, assigned = photon_auth.load_user_numbers()
assert phone == "+15551234567"
assert assigned == "+16282679185"
summary = photon_auth.credential_summary()
assert summary["phone_number"] == "+15551234567"
assert summary["assigned_phone_number"] == "+16282679185"
rendered: list[str] = []
photon_auth.print_credential_summary(rendered.append)
assert " my number : +15551234567" in rendered[0]
assert " assigned number : +16282679185" in rendered[0]
def test_load_user_numbers_falls_back_to_home_channel(
tmp_hermes_home: Path,
) -> None:
from hermes_cli.config import save_env_value
save_env_value("PHOTON_HOME_CHANNEL", "+15551234567")
phone, assigned = photon_auth.load_user_numbers()
assert phone == "+15551234567"
assert assigned is None
def test_refresh_user_numbers_reads_existing_assignment(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
photon_auth.store_user_numbers(phone_number="+15551234567")
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
assert kwargs.get("headers", {}).get("Authorization") == (
"Basic " + b64encode(b"sp:secret").decode("ascii")
)
assert url.endswith("/projects/sp/users/")
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
"id": "user-uuid",
"phoneNumber": "+1 (555) 123-4567",
"assignedPhoneNumber": "+16282679185",
}]}})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
phone, assigned = photon_auth.refresh_user_numbers("sp", "secret")
assert phone == "+15551234567"
assert assigned == "+16282679185"
assert photon_auth.load_user_numbers() == ("+15551234567", "+16282679185")
def test_load_project_credentials_env_override(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -365,7 +303,7 @@ def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None:
def test_create_user_rejects_invalid_phone() -> None:
with pytest.raises(ValueError, match="E.164"):
photon_auth.create_user("proj", "secret", phone_number="not-a-number")
photon_auth.create_user("tok", "proj", phone_number="not-a-number")
def test_create_user_posts_dashboard_shape(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -375,30 +313,27 @@ def test_create_user_posts_dashboard_shape(monkeypatch: pytest.MonkeyPatch) -> N
captured["url"] = url
captured["body"] = kwargs.get("json")
captured["headers"] = kwargs.get("headers")
return _FakeResponse(json_body={"succeed": True, "data": {
return _FakeResponse(json_body={"success": True, "user": {
"id": "user-uuid", "phoneNumber": "+15551234567",
}})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
user = photon_auth.create_user("proj-id", "secret", phone_number="+15551234567")
user = photon_auth.create_user("tok", "proj-id", phone_number="+15551234567")
assert user["id"] == "user-uuid"
assert captured["body"]["type"] == "shared"
assert captured["body"]["phoneNumber"] == "+15551234567"
assert captured["headers"]["Authorization"] == (
"Basic " + b64encode(b"proj-id:secret").decode("ascii")
)
assert captured["url"].endswith("/projects/proj-id/users/")
assert captured["headers"]["Authorization"] == "Bearer tok"
assert "/projects/proj-id/spectrum/users" in captured["url"]
def test_register_user_if_absent_dedup(monkeypatch: pytest.MonkeyPatch) -> None:
posted = {"n": 0}
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
return _FakeResponse(json_body=[{
"id": "u1",
"phoneNumber": "+1 (555) 123-4567",
"assignedPhoneNumber": "+16282679185",
}]}})
}])
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
posted["n"] += 1
@@ -408,7 +343,7 @@ def test_register_user_if_absent_dedup(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
# Same number, different formatting — should match and NOT create.
user, created = photon_auth.register_user_if_absent(
"proj", "secret", phone_number="+15551234567",
"tok", "proj", phone_number="+15551234567",
)
assert created is False
assert user["id"] == "u1"
@@ -431,15 +366,15 @@ def test_user_assigned_line() -> None:
def test_register_user_if_absent_creates(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"succeed": True, "data": {"users": []}})
return _FakeResponse(json_body=[])
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"succeed": True, "data": {"id": "u-new"}})
return _FakeResponse(json_body={"success": True, "user": {"id": "u-new"}})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
user, created = photon_auth.register_user_if_absent(
"proj", "secret", phone_number="+15551234567",
"tok", "proj", phone_number="+15551234567",
)
assert created is True
assert user["id"] == "u-new"
@@ -500,8 +435,6 @@ def test_credential_summary_no_secret_leak(
assert summary["project_key"].startswith("")
assert summary["spectrum_project_id"] == "sp-uuid"
assert summary["dashboard_project_id"] == "dash-uuid"
assert summary["phone_number"].startswith("✗ missing")
assert summary["assigned_phone_number"].startswith("✗ missing")
# ---------------------------------------------------------------------------
@@ -101,18 +101,6 @@ def _attachment_event(
}
def _voice_event(
content: Dict[str, Any], msg_id: str = "spc-msg-voice"
) -> Dict[str, Any]:
return {
"messageId": msg_id,
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
"sender": {"id": "+15551234567"},
"content": {"type": "voice", **content},
"timestamp": "2026-05-14T19:06:32.000Z",
}
@pytest.mark.asyncio
async def test_dispatch_attachment_without_bytes_surfaces_marker(
monkeypatch: pytest.MonkeyPatch,
@@ -168,64 +156,6 @@ async def test_dispatch_attachment_downloads_image(
cached.unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_dispatch_voice_downloads_audio(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Inbound Spectrum voice content is cached and routed to auto-STT."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
raw = b"OggS" + b"\x00" * 32
event = _voice_event(
{
"name": "note.ogg",
"mimeType": "audio/ogg",
"duration": 7,
"size": len(raw),
"data": base64.b64encode(raw).decode("ascii"),
"encoding": "base64",
}
)
await adapter._dispatch_inbound(event)
assert len(captured) == 1
ev = captured[0]
assert ev.message_type == MessageType.VOICE
assert ev.media_types == ["audio/ogg"]
assert len(ev.media_urls) == 1
cached = Path(ev.media_urls[0])
try:
assert cached.is_file()
assert cached.read_bytes() == raw
assert ev.text == "(voice)"
finally:
cached.unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_dispatch_voice_without_bytes_surfaces_marker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Metadata-only voice still tells the agent a voice note arrived."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
event = _voice_event(
{"name": "note.m4a", "mimeType": "audio/mp4", "duration": 12, "size": 12345}
)
await adapter._dispatch_inbound(event)
assert len(captured) == 1
ev = captured[0]
assert "Photon voice received" in ev.text
assert "note.m4a" in ev.text
assert "duration: 12s" in ev.text
assert ev.message_type == MessageType.VOICE
assert ev.media_urls == []
assert ev.media_types == []
@pytest.mark.asyncio
async def test_dispatch_attachment_downloads_document(
monkeypatch: pytest.MonkeyPatch,
-267
View File
@@ -902,115 +902,6 @@ def test_startup_runtime_detects_provider_for_model_env(monkeypatch):
)
def test_load_fallback_model_merges_chain_providers_first(monkeypatch):
# Parity with HermesCLI / gateway: fallback_providers stays first and keeps
# its order, with any distinct legacy fallback_model entry merged in after
# (deduped on provider/model/base_url).
fallback_chain = [
{"provider": "openrouter", "model": "openai/gpt-5.5"},
{"provider": "anthropic", "model": "claude-sonnet-4-6"},
]
monkeypatch.setattr(
server,
"_load_cfg",
lambda: {
"fallback_model": {"provider": "legacy", "model": "legacy-model"},
"fallback_providers": fallback_chain,
},
)
assert server._load_fallback_model() == [
{"provider": "openrouter", "model": "openai/gpt-5.5"},
{"provider": "anthropic", "model": "claude-sonnet-4-6"},
{"provider": "legacy", "model": "legacy-model"},
]
def test_make_agent_passes_configured_fallback_chain(monkeypatch):
captured = {}
fallback_chain = [
{"provider": "openrouter", "model": "openai/gpt-5.5"},
]
def fake_agent(**kwargs):
captured.update(kwargs)
return types.SimpleNamespace(model=kwargs.get("model"))
monkeypatch.delenv("HERMES_MODEL", raising=False)
monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
monkeypatch.setattr(
server,
"_load_cfg",
lambda: {
"model": {"default": "gpt-5.5", "provider": "openai-codex"},
"fallback_providers": fallback_chain,
},
)
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda requested=None, target_model=None: {
"provider": "openai-codex",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "token",
"api_mode": "codex_responses",
"credential_pool": None,
},
)
monkeypatch.setattr("run_agent.AIAgent", fake_agent)
monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: ["file"])
monkeypatch.setattr(server, "_get_db", lambda: None)
agent = server._make_agent("sid", "session-key")
assert agent.model == "gpt-5.5"
assert captured["fallback_model"] == fallback_chain
assert captured["platform"] == "tui"
def test_background_agent_kwargs_preserves_full_fallback_chain(monkeypatch):
chain = [
{"provider": "openrouter", "model": "openai/gpt-5.5"},
{"provider": "anthropic", "model": "claude-sonnet-4-6"},
]
agent = types.SimpleNamespace(
model="gpt-5.5",
provider="openai-codex",
_fallback_chain=chain,
)
monkeypatch.setattr(server, "_load_cfg", lambda: {"max_turns": 25})
monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: ["file"])
monkeypatch.setattr(server, "_get_db", lambda: None)
kwargs = server._background_agent_kwargs(agent, "task-id")
assert kwargs["fallback_model"] == chain
def test_background_agent_kwargs_preserves_empty_fallback_chain(monkeypatch):
agent = types.SimpleNamespace(
model="gpt-5.5",
provider="anthropic",
_fallback_chain=[],
)
monkeypatch.setattr(
server,
"_load_cfg",
lambda: {
"max_turns": 25,
"fallback_providers": [
{"provider": "openrouter", "model": "openai/gpt-5.5"},
],
},
)
monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: ["file"])
monkeypatch.setattr(server, "_get_db", lambda: None)
kwargs = server._background_agent_kwargs(agent, "task-id")
assert kwargs["fallback_model"] == []
def test_startup_runtime_resolves_short_alias_without_network(monkeypatch):
monkeypatch.setenv("HERMES_MODEL", "sonnet")
monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
@@ -2914,164 +2805,6 @@ def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch):
assert len(server._sessions["sid"]["attached_images"]) == 1
def test_file_attach_uploads_remote_file_into_session_workspace(monkeypatch, tmp_path):
"""Remote case: client path doesn't exist on gateway → decode data_url bytes."""
workspace = tmp_path / "workspace"
workspace.mkdir()
fake_cli = types.ModuleType("cli")
fake_cli._detect_file_drop = lambda raw: None
fake_cli._split_path_input = lambda raw: (raw, "")
fake_cli._resolve_attachment_path = lambda raw: None
server._sessions["sid"] = _session(cwd=str(workspace))
monkeypatch.setitem(sys.modules, "cli", fake_cli)
try:
resp = server.handle_request(
{
"id": "1",
"method": "file.attach",
"params": {
"session_id": "sid",
"path": "/Users/alice/Downloads/report.txt",
"name": "report.txt",
"data_url": "data:text/plain;base64,aGVsbG8gd29ybGQ=",
},
}
)
stored = workspace / ".hermes" / "desktop-attachments" / "report.txt"
assert resp["result"]["attached"] is True
assert resp["result"]["uploaded"] is True
assert resp["result"]["path"] == str(stored)
assert resp["result"]["ref_text"] == "@file:.hermes/desktop-attachments/report.txt"
assert stored.read_text(encoding="utf-8") == "hello world"
finally:
server._sessions.pop("sid", None)
def test_file_attach_copies_gateway_visible_file_outside_workspace(monkeypatch, tmp_path):
"""Local case: gateway can see the file but it's outside the workspace → copy in."""
workspace = tmp_path / "workspace"
workspace.mkdir()
source = tmp_path / "outside.txt"
source.write_text("outside workspace", encoding="utf-8")
fake_cli = types.ModuleType("cli")
fake_cli._detect_file_drop = lambda raw: None
fake_cli._split_path_input = lambda raw: (raw, "")
fake_cli._resolve_attachment_path = lambda raw: source
server._sessions["sid"] = _session(cwd=str(workspace))
monkeypatch.setitem(sys.modules, "cli", fake_cli)
try:
resp = server.handle_request(
{
"id": "1",
"method": "file.attach",
"params": {"session_id": "sid", "path": str(source)},
}
)
stored = workspace / ".hermes" / "desktop-attachments" / "outside.txt"
assert resp["result"]["attached"] is True
assert resp["result"]["uploaded"] is True
assert resp["result"]["ref_text"] == "@file:.hermes/desktop-attachments/outside.txt"
assert stored.read_text(encoding="utf-8") == "outside workspace"
finally:
server._sessions.pop("sid", None)
def test_file_attach_uses_in_workspace_file_without_copying(monkeypatch, tmp_path):
"""Local case: file already inside the workspace → ref it directly, no copy."""
workspace = tmp_path / "workspace"
(workspace / "data").mkdir(parents=True)
source = workspace / "data" / "exam.csv"
source.write_text("a,b,c\n1,2,3\n", encoding="utf-8")
fake_cli = types.ModuleType("cli")
fake_cli._detect_file_drop = lambda raw: None
fake_cli._split_path_input = lambda raw: (raw, "")
fake_cli._resolve_attachment_path = lambda raw: source
server._sessions["sid"] = _session(cwd=str(workspace))
monkeypatch.setitem(sys.modules, "cli", fake_cli)
try:
resp = server.handle_request(
{
"id": "1",
"method": "file.attach",
"params": {"session_id": "sid", "path": str(source)},
}
)
assert resp["result"]["attached"] is True
assert resp["result"]["uploaded"] is False
assert resp["result"]["ref_text"] == "@file:data/exam.csv"
# No copy: nothing staged under desktop-attachments.
assert not (workspace / ".hermes" / "desktop-attachments").exists()
finally:
server._sessions.pop("sid", None)
def test_file_attach_errors_when_unresolvable_and_no_bytes(monkeypatch, tmp_path):
"""Remote path not on gateway and no data_url → actionable error, not a stage."""
workspace = tmp_path / "workspace"
workspace.mkdir()
fake_cli = types.ModuleType("cli")
fake_cli._detect_file_drop = lambda raw: None
fake_cli._split_path_input = lambda raw: (raw, "")
fake_cli._resolve_attachment_path = lambda raw: None
server._sessions["sid"] = _session(cwd=str(workspace))
monkeypatch.setitem(sys.modules, "cli", fake_cli)
try:
resp = server.handle_request(
{
"id": "1",
"method": "file.attach",
"params": {"session_id": "sid", "path": "/Users/alice/missing.txt"},
}
)
assert "error" in resp
assert "no data_url" in resp["error"]["message"]
finally:
server._sessions.pop("sid", None)
def test_file_attach_quotes_ref_with_spaces(monkeypatch, tmp_path):
"""Staged names with spaces must be backtick-quoted so the @file: ref parses."""
workspace = tmp_path / "workspace"
workspace.mkdir()
fake_cli = types.ModuleType("cli")
fake_cli._detect_file_drop = lambda raw: None
fake_cli._split_path_input = lambda raw: (raw, "")
fake_cli._resolve_attachment_path = lambda raw: None
server._sessions["sid"] = _session(cwd=str(workspace))
monkeypatch.setitem(sys.modules, "cli", fake_cli)
try:
resp = server.handle_request(
{
"id": "1",
"method": "file.attach",
"params": {
"session_id": "sid",
"name": "my exam schedule.csv",
"data_url": "data:text/csv;base64,YSxiCg==",
},
}
)
assert resp["result"]["attached"] is True
assert resp["result"]["ref_text"] == "@file:`.hermes/desktop-attachments/my exam schedule.csv`"
finally:
server._sessions.pop("sid", None)
def test_commands_catalog_surfaces_quick_commands(monkeypatch):
monkeypatch.setattr(
server,
-1
View File
@@ -109,7 +109,6 @@ def test_ensure_docker_available_uses_resolved_executable(monkeypatch):
"capture_output": True,
"text": True,
"timeout": 5,
"stdin": subprocess.DEVNULL,
})
]
@@ -1,70 +0,0 @@
"""Verify that TUI-context subprocess calls specify stdin=.
This is the pytest wrapper for scripts/check_subprocess_stdin.py.
It runs as part of the test suite so CI catches regressions when new
subprocess calls are added without stdin=subprocess.DEVNULL.
"""
import importlib.util
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "check_subprocess_stdin.py"
def _load_guard():
spec = importlib.util.spec_from_file_location("_stdin_guard", SCRIPT)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_all_tui_subprocess_calls_have_stdin():
"""Every subprocess.run/Popen in TUI-context code must set stdin=."""
result = subprocess.run(
[sys.executable, str(SCRIPT)],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"subprocess stdin= check failed:\n{result.stdout}\n{result.stderr}"
)
def test_oauth_setup_token_keeps_inherited_stdin():
"""The interactive 'claude setup-token' login must NOT be muzzled.
Forcing stdin=subprocess.DEVNULL here would feed the OAuth prompt EOF and
break interactive token setup. A blanket DEVNULL sweep over TUI-context
subprocess calls must leave this one inheriting stdin. Regression guard for
the over-application caught while salvaging the stdin-EOF fix.
"""
src = (REPO_ROOT / "agent" / "anthropic_adapter.py").read_text()
assert 'subprocess.run([claude_path, "setup-token"])' in src, (
"interactive setup-token call changed shape; re-verify it still "
"inherits stdin (no stdin=subprocess.DEVNULL)"
)
assert 'subprocess.run([claude_path, "setup-token"], stdin' not in src, (
"setup-token must inherit stdin so the user can complete the OAuth "
"login prompt; do not add stdin=subprocess.DEVNULL"
)
def test_inline_noqa_marker_exempts_a_call():
"""The guard honors an inline 'noqa: subprocess-stdin' exemption marker."""
guard = _load_guard()
flagged = guard.find_subprocess_calls(
"import subprocess\nsubprocess.run(['ls'])\n", "x.py"
)
assert len(flagged) == 1, "unmarked missing-stdin call should be flagged"
exempt = guard.find_subprocess_calls(
"import subprocess\nsubprocess.run(['ls']) # noqa: subprocess-stdin\n",
"x.py",
)
assert exempt == [], "inline marker should exempt the call"
+250 -104
View File
@@ -1,151 +1,297 @@
"""Regression tests for terminal config -> env-var bridging.
``terminal_tool._get_env_config()`` reads ALL terminal settings from
``os.environ`` (TERMINAL_*). config.yaml values therefore have to be bridged
into env vars at startup by every entry point:
terminal_tool._get_env_config() reads ALL terminal settings from os.environ
(TERMINAL_*). config.yaml values therefore have to be bridged into env vars
at startup, by THREE separate code paths:
1. cli.py -> CLI / TUI startup
2. gateway/run.py -> gateway / messaging platforms
3. hermes_cli/config.py:set_config_value -> one-shot ``hermes config set ``
1. cli.py -> ``env_mappings`` dict (CLI / TUI startup)
2. gateway/run.py -> ``_terminal_env_map`` dict (gateway / messaging
platforms)
3. hermes_cli/config.py:save_config_value
-> ``_config_to_env_sync`` dict (one-shot when the
user runs ``hermes config set ``)
If any one of these bridges a different set of ``terminal.*`` keys, the
corresponding config.yaml setting silently does nothing for that entry point.
This bug class shipped more than once (``docker_run_as_host_user``,
``docker_mount_cwd_to_workspace``, and the ``docker_extra_args`` / ``modal_mode``
gaps).
If any one of these is missing a key, the corresponding config.yaml setting
silently does nothing for that entry-point. This bug already shipped once
for ``docker_run_as_host_user`` (gateway and CLI maps) and once for
``docker_mount_cwd_to_workspace`` (gateway map).
The fix that makes the drift structurally impossible: all three paths now
derive their mapping from the single source of truth
``hermes_cli.config.TERMINAL_CONFIG_ENV_MAP`` instead of hand-maintaining
parallel dict literals. These tests assert that invariant against the LIVE
imported objects no source-text parsing, so they don't break when a map is
refactored (renamed, inlined, or built via comprehension) as long as the
behavior holds.
This test guards against future drift by extracting all three maps via source
inspection and asserting they all bridge the same set of writable
``terminal.*`` keys. Source inspection (rather than importing the live
dicts) keeps the test independent of the user's ~/.hermes/config.yaml and
mirrors the pattern used in tests/hermes_cli/test_config_drift.py.
"""
from __future__ import annotations
import ast
import inspect
def _shared_map() -> dict[str, str]:
from hermes_cli.config import TERMINAL_CONFIG_ENV_MAP
return dict(TERMINAL_CONFIG_ENV_MAP)
def _extract_dict_values(source: str, dict_name: str) -> set[str]:
"""Return the set of *value* strings in `dict_name = { "k": "VALUE", ... }`.
We parse the source with ast (so multi-line dicts and comments are
handled) instead of regex. The first matching assignment wins.
"""
tree = ast.parse(source)
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
targets = [t for t in node.targets if isinstance(t, ast.Name)]
if not any(t.id == dict_name for t in targets):
continue
if not isinstance(node.value, ast.Dict):
continue
out: set[str] = set()
for k, v in zip(node.value.keys, node.value.values):
if isinstance(k, ast.Constant) and isinstance(v, ast.Constant):
if isinstance(v.value, str):
out.add(v.value)
return out
raise AssertionError(f"Could not find `{dict_name} = {{...}}` literal in source")
def _extract_dict_keys(source: str, dict_name: str) -> set[str]:
"""Return the set of *key* strings in `dict_name = { "KEY": "v", ... }`."""
tree = ast.parse(source)
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
targets = [t for t in node.targets if isinstance(t, ast.Name)]
if not any(t.id == dict_name for t in targets):
continue
if not isinstance(node.value, ast.Dict):
continue
out: set[str] = set()
for k in node.value.keys:
if isinstance(k, ast.Constant) and isinstance(k.value, str):
out.add(k.value)
return out
raise AssertionError(f"Could not find `{dict_name} = {{...}}` literal in source")
def _cli_env_map_keys() -> set[str]:
"""terminal config keys bridged by cli.load_cli_config()."""
import cli
source = inspect.getsource(cli.load_cli_config)
return _extract_dict_keys(source, "env_mappings")
def _gateway_env_map_keys() -> set[str]:
"""terminal config keys bridged by gateway/run.py at module load."""
# gateway/run.py builds the dict at module top-level (not inside a
# function), so inspect the whole module source.
import gateway.run as gr
source = inspect.getsource(gr)
return _extract_dict_keys(source, "_terminal_env_map")
def _save_config_env_sync_keys() -> set[str]:
"""terminal config keys bridged by ``hermes config set foo bar``."""
from hermes_cli import config as hc_config
source = inspect.getsource(hc_config.set_config_value)
keys = _extract_dict_keys(source, "_config_to_env_sync")
# set_config_value uses fully-qualified ``terminal.foo`` keys; strip the
# prefix so we can compare against the other two maps which use bare
# leaf keys.
return {k.split(".", 1)[1] for k in keys if k.startswith("terminal.")}
# Keys present in cli.py env_mappings but intentionally absent from
# gateway/run.py or set_config_value. Each entry must be justified.
_CLI_ONLY_OK = frozenset({
# `env_type` is a legacy YAML key alias for `backend` that cli.py
# accepts for backwards-compat with older cli-config.yaml. The
# gateway path normalizes on the canonical `backend` key, which is
# also in the map and handles the same bridging. See cli.py ~line 515.
"env_type",
# sudo_password is not a terminal-backend option — it's a credential
# used across backends, bridged to $SUDO_PASSWORD (not TERMINAL_*).
# Treating it as terminal-only would be misleading.
"sudo_password",
})
def _terminal_tool_env_var_names() -> set[str]:
"""All TERMINAL_* env vars actually consumed by terminal_tool."""
import inspect
import re
import tools.terminal_tool as tt
source = inspect.getsource(tt)
# Every os.getenv("TERMINAL_X", ...) / _parse_env_var("TERMINAL_X", ...) etc.
# Naive scan: every os.getenv("TERMINAL_X", ...) and _parse_env_var("TERMINAL_X", ...).
import re
pat = re.compile(r'["\'](TERMINAL_[A-Z0-9_]+)["\']')
return set(pat.findall(source))
def test_shared_map_covers_critical_bridged_keys():
"""The shared bridge map must carry the load-bearing docker/container keys.
def test_cli_and_gateway_env_maps_agree():
"""cli.py and gateway/run.py must bridge the same set of terminal keys.
Pins the specific keys whose absence previously shipped as silent
config-does-nothing bugs, so a future trim of TERMINAL_CONFIG_ENV_MAP
can't drop one without this failing.
Both feed the same downstream consumer (terminal_tool). Drift between
them means a config.yaml setting that "works in CLI mode but not gateway
mode" (or vice-versa) — the bug class that shipped twice already.
"""
keys = set(_shared_map().keys())
cli_keys = _cli_env_map_keys() - _CLI_ONLY_OK
gw_keys = _gateway_env_map_keys()
# Normalize the legacy `env_type` alias: cli.py accepts both `env_type`
# and `backend` as source keys for TERMINAL_ENV; gateway only accepts
# `backend`. Since cli.py copies `backend` → `env_type` before the
# lookup, they're equivalent. Remove `backend` from the gateway side
# to avoid a spurious "backend missing from cli" failure.
gw_keys = gw_keys - {"backend"}
missing_in_gateway = cli_keys - gw_keys
missing_in_cli = gw_keys - cli_keys
assert not missing_in_gateway, (
f"Keys in cli.py env_mappings but missing from gateway/run.py "
f"_terminal_env_map: {sorted(missing_in_gateway)}. Add them to "
f"both maps (same bug class as docker_run_as_host_user shipping "
f"wired in cli but not gateway in April 2026)."
)
assert not missing_in_cli, (
f"Keys in gateway/run.py _terminal_env_map but missing from cli.py "
f"env_mappings: {sorted(missing_in_cli)}. Add them to both maps."
)
def test_save_config_set_supports_critical_bridged_keys():
"""``hermes config set terminal.X true`` must propagate to .env for
known-critical keys. This used to be an all-keys invariant but the SSH
terminal keys (ssh_*) aren't in _config_to_env_sync and are instead
handled via the separate api_keys TERMINAL_SSH_* fallback path or
user-edits-yaml-directly.
Until those gaps are audited and fixed, pin the specific keys that are
load-bearing for the docker backend so the bugs we fixed cannot silently
regress. (docker_volumes / docker_forward_env, previously listed here as
gaps, are now bridged see the dedicated tests below.)
"""
save_keys = _save_config_env_sync_keys()
required = {
"backend",
"cwd",
"timeout",
"docker_image",
"docker_run_as_host_user",
"docker_mount_cwd_to_workspace",
"docker_env",
"docker_volumes",
"docker_forward_env",
"docker_extra_args",
"docker_persist_across_processes",
"docker_orphan_reaper",
"modal_mode",
"backend",
"docker_image",
"container_cpu",
"container_memory",
"container_disk",
"container_persistent",
}
missing = required - keys
missing = required - save_keys
assert not missing, (
f"TERMINAL_CONFIG_ENV_MAP (hermes_cli/config.py) is missing load-bearing "
f"terminal keys: {sorted(missing)}. Every entry point derives its "
f"config->env bridge from this map, so a missing key silently disables "
f"that setting everywhere."
f"`hermes config set terminal.X` doesn't sync these load-bearing "
f"keys to .env: {sorted(missing)}. Add them to _config_to_env_sync "
f"in hermes_cli/config.py:set_config_value."
)
def test_every_mapped_env_var_is_consumed_by_terminal_tool():
"""Each ``TERMINAL_*`` var the shared map bridges must be read by terminal_tool.
def test_docker_run_as_host_user_is_bridged_everywhere():
"""Explicit pin for the bug we just fixed.
A mapping that points at an env var terminal_tool never reads is dead
bridging the config key looks wired but does nothing. (Non-``TERMINAL_``
targets like ``SUDO_PASSWORD`` are bridged but read elsewhere, so this only
checks the ``TERMINAL_`` namespace.)
docker_run_as_host_user was added to terminal_tool._get_env_config and
DockerEnvironment but NOT to cli.py's env_mappings or gateway/run.py's
_terminal_env_map, so ``terminal.docker_run_as_host_user: true`` in
config.yaml had no effect at runtime. This guard makes the regression
impossible to reintroduce silently.
"""
mapped = {v for v in _shared_map().values() if v.startswith("TERMINAL_")}
consumed = _terminal_tool_env_var_names()
dead = mapped - consumed
assert not dead, (
f"TERMINAL_CONFIG_ENV_MAP bridges these env vars that terminal_tool "
f"never reads: {sorted(dead)}. Either terminal_tool should consume "
f"them or they shouldn't be in the map."
)
assert "docker_run_as_host_user" in _cli_env_map_keys()
assert "docker_run_as_host_user" in _gateway_env_map_keys()
assert "docker_run_as_host_user" in _save_config_env_sync_keys()
assert "TERMINAL_DOCKER_RUN_AS_HOST_USER" in _terminal_tool_env_var_names()
def test_cli_bridge_derives_from_shared_map():
"""cli.load_cli_config must bridge exactly the shared map's keys.
cli.py derives ``env_mappings`` from TERMINAL_CONFIG_ENV_MAP with two
documented deltas: the legacy ``env_type`` alias replaces ``backend``, and
``sudo_password`` is added (a cross-backend credential, not a terminal.*
setting). This asserts the live module-level source contains the
derivation (so the literal-duplicate regression can't return) and that the
consuming loop is still present.
def test_docker_mount_cwd_to_workspace_is_bridged_everywhere():
"""Same regression class — docker_mount_cwd_to_workspace was missing from
gateway/run.py's _terminal_env_map until the docker_run_as_host_user
audit caught it.
"""
import inspect
import cli
source = inspect.getsource(cli.load_cli_config)
assert "TERMINAL_CONFIG_ENV_MAP" in source, (
"cli.load_cli_config no longer derives its terminal env bridge from "
"TERMINAL_CONFIG_ENV_MAP — it must, to avoid drift from the gateway "
"and `hermes config set` paths."
)
assert "env_mappings" in source
assert "docker_mount_cwd_to_workspace" in _cli_env_map_keys()
assert "docker_mount_cwd_to_workspace" in _gateway_env_map_keys()
assert "docker_mount_cwd_to_workspace" in _save_config_env_sync_keys()
assert "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE" in _terminal_tool_env_var_names()
def test_gateway_bridge_derives_from_shared_map():
"""gateway/run.py must bridge exactly the shared map's keys.
def test_docker_env_is_bridged_everywhere():
"""Regression pin for docker_env config key being silently ignored.
The gateway uses the canonical ``backend`` key (no env_type alias) and no
sudo_password, so it maps over TERMINAL_CONFIG_ENV_MAP 1:1.
``terminal.docker_env`` in config.yaml specifies extra env vars to inject
into the Docker container at runtime. The key was present in
_create_environment's container_config consumer (line ~1130) but never
bridged from config.yaml to TERMINAL_DOCKER_ENV, so the dict was always
empty regardless of what the user set. Guard all four bridging points so
this cannot regress.
"""
import inspect
import gateway.run as gr
source = inspect.getsource(gr)
assert "TERMINAL_CONFIG_ENV_MAP" in source, (
"gateway/run.py no longer derives its terminal env bridge from "
"TERMINAL_CONFIG_ENV_MAP — it must, to avoid drift from the CLI and "
"`hermes config set` paths."
)
assert "docker_env" in _cli_env_map_keys()
assert "docker_env" in _gateway_env_map_keys()
assert "docker_env" in _save_config_env_sync_keys()
assert "TERMINAL_DOCKER_ENV" in _terminal_tool_env_var_names()
def test_set_config_value_uses_shared_map():
"""``hermes config set terminal.X`` bridges via the shared map.
def test_docker_persist_across_processes_is_bridged_everywhere():
"""Regression pin for the cross-process container reuse toggle.
set_config_value calls terminal_config_env_var_for_key(), which looks up
TERMINAL_CONFIG_ENV_MAP. Verify the lookup is wired and resolves a known
key, rather than parsing for a (now-removed) inline dict literal.
``terminal.docker_persist_across_processes`` (issue #20561) controls
whether ``DockerEnvironment.__init__`` probes for and reuses an existing
labeled container at startup, and whether ``cleanup()`` removes the
container on Hermes exit or just stops it (keeping it for the next
process). Same four-bridge invariant as docker_run_as_host_user /
docker_env / docker_mount_cwd_to_workspace drift between any of the
four sites means ``terminal.docker_persist_across_processes: false`` in
config.yaml silently does nothing for that entry point, leaving the
user unable to opt out of the documented "ONE long-lived container
shared across sessions" behavior.
"""
from hermes_cli.config import terminal_config_env_var_for_key
assert "docker_persist_across_processes" in _cli_env_map_keys()
assert "docker_persist_across_processes" in _gateway_env_map_keys()
assert "docker_persist_across_processes" in _save_config_env_sync_keys()
assert "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES" in _terminal_tool_env_var_names()
assert terminal_config_env_var_for_key("terminal.docker_image") == "TERMINAL_DOCKER_IMAGE"
assert terminal_config_env_var_for_key("terminal.modal_mode") == "TERMINAL_MODAL_MODE"
# Non-terminal keys are not bridged.
assert terminal_config_env_var_for_key("tts.provider") is None
def test_docker_orphan_reaper_is_bridged_everywhere():
"""Regression pin for the startup orphan reaper toggle (issue #20561).
``terminal.docker_orphan_reaper`` controls whether Hermes sweeps stale
Exited containers from prior SIGKILL'd processes at startup. Same
four-site bridge invariant drift means
``terminal.docker_orphan_reaper: false`` silently does nothing for one
entry point, and the reaper either runs when the operator disabled it
or fails to run when they enabled it.
"""
assert "docker_orphan_reaper" in _cli_env_map_keys()
assert "docker_orphan_reaper" in _gateway_env_map_keys()
assert "docker_orphan_reaper" in _save_config_env_sync_keys()
assert "TERMINAL_DOCKER_ORPHAN_REAPER" in _terminal_tool_env_var_names()
def test_docker_volumes_is_bridged_everywhere():
"""Regression pin for ``terminal.docker_volumes`` being silently dropped by
``hermes config set``.
The JSON list of ``host:container`` bind mounts was bridged by cli.py and
gateway/run.py and consumed by terminal_tool (via json.loads), but was
missing from set_config_value's _config_to_env_sync. So
``hermes config set terminal.docker_volumes '["/host:/workspace"]'`` wrote
config.yaml yet left the running process's TERMINAL_DOCKER_VOLUMES stale —
the mounts didn't apply until a full restart. Same four-site bridge
invariant as docker_env / docker_run_as_host_user.
"""
assert "docker_volumes" in _cli_env_map_keys()
assert "docker_volumes" in _gateway_env_map_keys()
assert "docker_volumes" in _save_config_env_sync_keys()
assert "TERMINAL_DOCKER_VOLUMES" in _terminal_tool_env_var_names()
def test_docker_forward_env_is_bridged_everywhere():
"""Regression pin for ``terminal.docker_forward_env`` — the sibling gap to
docker_volumes.
The JSON list of host env-var names forwarded into the container was
bridged by cli.py and gateway/run.py and consumed by terminal_tool (via
json.loads), but missing from set_config_value's _config_to_env_sync, so
``hermes config set terminal.docker_forward_env '["GITHUB_TOKEN"]'`` had no
effect on the running process until restart.
"""
assert "docker_forward_env" in _cli_env_map_keys()
assert "docker_forward_env" in _gateway_env_map_keys()
assert "docker_forward_env" in _save_config_env_sync_keys()
assert "TERMINAL_DOCKER_FORWARD_ENV" in _terminal_tool_env_var_names()
-2
View File
@@ -307,7 +307,6 @@ def _run_git(
timeout=timeout,
env=env,
cwd=str(normalized_working_dir),
stdin=subprocess.DEVNULL,
)
ok = result.returncode == 0
stdout = result.stdout.strip()
@@ -427,7 +426,6 @@ def _init_store(store: Path, working_dir: str) -> Optional[str]:
["git", "init", "--bare", str(store)],
capture_output=True, text=True,
env=init_env, timeout=_GIT_TIMEOUT,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
return f"Shadow store init failed: {result.stderr.strip()}"
-1
View File
@@ -1618,7 +1618,6 @@ def _is_usable_python(python_path: str) -> bool:
timeout=5,
capture_output=True,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
stdin=subprocess.DEVNULL,
)
return result.returncode == 0
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
-1
View File
@@ -65,7 +65,6 @@ def _run(cmd: list[str], timeout: float = 3.0) -> tuple[int, str, str]:
text=True,
timeout=timeout,
check=False,
stdin=subprocess.DEVNULL,
)
return result.returncode, (result.stdout or "").strip(), (result.stderr or "").strip()
except FileNotFoundError:
+1 -17
View File
@@ -177,7 +177,6 @@ def reap_orphan_containers(
listing = subprocess.run(
[docker, "ps", "-a", *filters, "--format", "{{.ID}}"],
capture_output=True, text=True, timeout=15, check=False,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("orphan reaper docker ps failed: %s", e)
@@ -211,7 +210,6 @@ def reap_orphan_containers(
result = subprocess.run(
[docker, "rm", "-f", cid],
capture_output=True, text=True, timeout=30,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0:
removed += 1
@@ -241,7 +239,6 @@ def _container_finished_at(docker_exe: str, container_id: str):
result = subprocess.run(
[docker_exe, "inspect", "--format", "{{.State.FinishedAt}}", container_id],
capture_output=True, text=True, timeout=10, check=False,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("orphan reaper docker inspect %s failed: %s", container_id[:12], e)
@@ -384,7 +381,6 @@ def _image_uses_init_entrypoint(docker_exe: str, image: str) -> bool:
capture_output=True,
text=True,
timeout=15,
stdin=subprocess.DEVNULL,
)
except (subprocess.SubprocessError, OSError) as e:
logger.debug("Docker: could not inspect entrypoint for %s: %s", image, e)
@@ -457,7 +453,6 @@ def _ensure_docker_available() -> None:
capture_output=True,
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
logger.error(
@@ -838,7 +833,6 @@ class DockerEnvironment(BaseEnvironment):
text=True,
timeout=30,
check=True,
stdin=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(
@@ -877,7 +871,6 @@ class DockerEnvironment(BaseEnvironment):
text=True,
timeout=120, # image pull may take a while
check=True,
stdin=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
# Docker may create the container object before `docker run`
@@ -894,7 +887,6 @@ class DockerEnvironment(BaseEnvironment):
subprocess.run(
[self._docker_exe, "rm", "-f", container_name],
capture_output=True, timeout=10,
stdin=subprocess.DEVNULL,
)
raise
self._container_id = result.stdout.strip()
@@ -1005,7 +997,6 @@ class DockerEnvironment(BaseEnvironment):
subprocess.run(
[self._docker_exe, "start", cid],
capture_output=True, text=True, timeout=30, check=True,
stdin=subprocess.DEVNULL,
)
self._container_id = cid
logger.info("Recovery: restarted container %s", cid[:12])
@@ -1036,7 +1027,6 @@ class DockerEnvironment(BaseEnvironment):
]
result = subprocess.run(
run_cmd, capture_output=True, text=True, timeout=120, check=True,
stdin=subprocess.DEVNULL,
)
self._container_id = result.stdout.strip()
self._container_name = new_name
@@ -1091,7 +1081,6 @@ class DockerEnvironment(BaseEnvironment):
result = subprocess.run(
[docker, "info", "--format", "{{.Driver}}"],
capture_output=True, text=True, timeout=10,
stdin=subprocess.DEVNULL,
)
driver = result.stdout.strip().lower()
if driver != "overlay2":
@@ -1102,15 +1091,13 @@ class DockerEnvironment(BaseEnvironment):
probe = subprocess.run(
[docker, "create", "--storage-opt", "size=1m", "hello-world"],
capture_output=True, text=True, timeout=15,
stdin=subprocess.DEVNULL,
)
if probe.returncode == 0:
# Clean up the created container
container_id = probe.stdout.strip()
if container_id:
subprocess.run([docker, "rm", container_id],
capture_output=True, timeout=5,
stdin=subprocess.DEVNULL)
capture_output=True, timeout=5)
_storage_opt_ok = True
else:
_storage_opt_ok = False
@@ -1145,7 +1132,6 @@ class DockerEnvironment(BaseEnvironment):
text=True,
timeout=10,
check=False,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("docker ps probe failed: %s — will start a fresh container", e)
@@ -1262,7 +1248,6 @@ class DockerEnvironment(BaseEnvironment):
subprocess.run(
[docker_exe, "stop", "-t", "10", container_id],
capture_output=True, timeout=30,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.warning("docker stop %s timed out / failed: %s", log_id, e)
@@ -1271,7 +1256,6 @@ class DockerEnvironment(BaseEnvironment):
subprocess.run(
[docker_exe, "rm", "-f", container_id],
capture_output=True, timeout=30,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.warning("docker rm -f %s failed: %s", log_id, e)
+1 -4
View File
@@ -46,7 +46,6 @@ def _ensure_singularity_available() -> str:
try:
result = subprocess.run(
[exe, "version"], capture_output=True, text=True, timeout=10,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
raise RuntimeError(
@@ -137,7 +136,6 @@ def _get_or_build_sif(image: str, executable: str = "apptainer") -> str:
result = subprocess.run(
[executable, "build", str(sif_path), image],
capture_output=True, text=True, timeout=600, env=env,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
logger.warning("SIF build failed, falling back to docker:// URL")
@@ -220,7 +218,7 @@ class SingularityEnvironment(BaseEnvironment):
cmd.extend([str(self.image), self.instance_id])
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
raise RuntimeError(f"Failed to start instance: {result.stderr}")
self._instance_started = True
@@ -252,7 +250,6 @@ class SingularityEnvironment(BaseEnvironment):
subprocess.run(
[self.executable, "instance", "stop", self.instance_id],
capture_output=True, text=True, timeout=30,
stdin=subprocess.DEVNULL,
)
logger.info("Singularity instance %s stopped", self.instance_id)
except Exception as e:
-4
View File
@@ -365,7 +365,6 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
r = subprocess.run(
[uv_bin, "pip", "install", *specs],
capture_output=True, text=True, timeout=timeout, env=uv_env,
stdin=subprocess.DEVNULL,
)
if r.returncode == 0:
return _InstallResult(True, r.stdout or "", r.stderr or "")
@@ -379,7 +378,6 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
probe = subprocess.run(
pip_cmd + ["--version"],
capture_output=True, text=True, timeout=15,
stdin=subprocess.DEVNULL,
)
if probe.returncode != 0:
raise FileNotFoundError("pip not in venv")
@@ -388,7 +386,6 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
capture_output=True, text=True, timeout=120, check=True,
stdin=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
return _InstallResult(False, "",
@@ -398,7 +395,6 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
r = subprocess.run(
pip_cmd + ["install", *specs],
capture_output=True, text=True, timeout=timeout,
stdin=subprocess.DEVNULL,
)
return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "")
except subprocess.TimeoutExpired as e:
-1
View File
@@ -472,7 +472,6 @@ class ProcessRegistry:
text=True,
timeout=10,
creationflags=windows_hide_flags(),
stdin=subprocess.DEVNULL,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
try:
-1
View File
@@ -301,7 +301,6 @@ class GitHubAuth:
result = subprocess.run(
["gh", "auth", "token"],
capture_output=True, text=True, timeout=5,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
+2 -2
View File
@@ -2436,13 +2436,13 @@ def check_terminal_requirements() -> bool:
if not docker:
logger.error("Docker executable not found in PATH or common install locations")
return False
result = subprocess.run([docker, "version"], capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
result = subprocess.run([docker, "version"], capture_output=True, timeout=5)
return result.returncode == 0
elif env_type == "singularity":
executable = shutil.which("apptainer") or shutil.which("singularity")
if executable:
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5)
return result.returncode == 0
return False
-2
View File
@@ -288,7 +288,6 @@ def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool |
capture_output=True,
text=True,
timeout=15,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0:
logger.info("cosign provenance verification passed")
@@ -735,7 +734,6 @@ def check_command_security(command: str) -> dict:
capture_output=True,
text=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
except OSError as exc:
# Covers FileNotFoundError, PermissionError, exec format error.
+4 -5
View File
@@ -490,7 +490,6 @@ def _terminate_command_stt_process_tree(proc: subprocess.Popen) -> None:
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
stdin=subprocess.DEVNULL,
)
except Exception:
proc.kill()
@@ -556,7 +555,7 @@ def _run_command_stt(command: str, timeout: float) -> subprocess.CompletedProces
else:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
proc = subprocess.Popen(command, **popen_kwargs)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired as exc:
@@ -1187,7 +1186,7 @@ def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str],
command = [ffmpeg, "-y", "-i", file_path, converted_path]
try:
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300)
return converted_path, None
except subprocess.TimeoutExpired:
logger.error("ffmpeg conversion timed out for %s", file_path)
@@ -1233,9 +1232,9 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any]
# User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode.
use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip())
if use_shell:
subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300)
else:
subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300)
txt_files = sorted(Path(output_dir).glob("*.txt"))
+6 -9
View File
@@ -693,7 +693,6 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None:
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
stdin=subprocess.DEVNULL,
)
except Exception:
proc.kill()
@@ -746,7 +745,7 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces
else:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
proc = subprocess.Popen(command, **popen_kwargs)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired as exc:
@@ -883,7 +882,6 @@ def _convert_to_opus(mp3_path: str) -> Optional[str]:
["ffmpeg", "-i", mp3_path, "-acodec", "libopus",
"-ac", "1", "-b:a", "64k", "-vbr", "off", ogg_path, "-y"],
capture_output=True, timeout=30,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
logger.warning("ffmpeg conversion failed with return code %d: %s",
@@ -1506,7 +1504,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
]
else:
cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL)
result = subprocess.run(cmd, capture_output=True, timeout=30)
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", errors="ignore")[:300]
raise RuntimeError(f"ffmpeg conversion failed: {stderr}")
@@ -1589,7 +1587,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
"--device", device,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
stderr = result.stderr.strip()
# Filter out the "OK:" line from stderr
@@ -1601,7 +1599,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
subprocess.run(conv_cmd, check=True, timeout=30)
os.remove(wav_path)
else:
# No ffmpeg — just rename the WAV to the expected path
@@ -1672,7 +1670,6 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str:
[_sys.executable, "-m", "piper.download_voices", voice,
"--download-dir", str(download_dir)],
capture_output=True, text=True, timeout=300,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
@@ -1760,7 +1757,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
subprocess.run(conv_cmd, check=True, timeout=30)
try:
os.remove(wav_path)
except OSError:
@@ -1826,7 +1823,7 @@ def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any])
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
subprocess.run(conv_cmd, check=True, timeout=30)
os.remove(wav_path)
else:
# No ffmpeg — rename the WAV to the expected path
+3 -4
View File
@@ -75,7 +75,6 @@ def _termux_api_app_installed() -> bool:
text=True,
timeout=5,
check=False,
stdin=subprocess.DEVNULL,
)
return "package:com.termux.api" in (result.stdout or "")
except Exception:
@@ -389,7 +388,7 @@ class TermuxAudioRecorder:
"-c", str(CHANNELS),
]
try:
subprocess.run(command, capture_output=True, text=True, timeout=15, check=True, stdin=subprocess.DEVNULL)
subprocess.run(command, capture_output=True, text=True, timeout=15, check=True)
except subprocess.CalledProcessError as e:
details = (e.stderr or e.stdout or str(e)).strip()
raise RuntimeError(f"Termux microphone start failed: {details}") from e
@@ -406,7 +405,7 @@ class TermuxAudioRecorder:
mic_cmd = _termux_microphone_command()
if not mic_cmd:
return
subprocess.run([mic_cmd, "-q"], capture_output=True, text=True, timeout=15, check=False, stdin=subprocess.DEVNULL)
subprocess.run([mic_cmd, "-q"], capture_output=True, text=True, timeout=15, check=False)
def stop(self) -> Optional[str]:
with self._lock:
@@ -1096,7 +1095,7 @@ def play_audio_file(file_path: str) -> bool:
exe = shutil.which(cmd[0])
if exe:
try:
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
with _playback_lock:
_active_playback = proc
proc.wait(timeout=300)
+4 -227
View File
@@ -1041,7 +1041,6 @@ def _git_branch_for_cwd(cwd: str) -> str:
text=True,
timeout=1.5,
check=False,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0:
branch = result.stdout.strip()
@@ -1053,7 +1052,6 @@ def _git_branch_for_cwd(cwd: str) -> str:
text=True,
timeout=1.5,
check=False,
stdin=subprocess.DEVNULL,
)
return head.stdout.strip() if head.returncode == 0 else ""
except Exception:
@@ -1992,8 +1990,7 @@ def _current_profile_name() -> str:
# backend reporting less than its required value (or none at all — a pre-GUI
# checkout), surfacing a one-click "update to align" prompt instead of failing
# cryptically downstream. Bump whenever the desktop's backend contract changes.
# v2: adds the file.attach RPC (remote-gateway non-image file upload).
DESKTOP_BACKEND_CONTRACT = 2
DESKTOP_BACKEND_CONTRACT = 1
def _session_info(agent, session: dict | None = None) -> dict:
@@ -2588,29 +2585,6 @@ def _parse_tui_skills_env() -> list[str]:
return skills
def _load_fallback_model():
"""Return the configured fallback chain for TUI-created agents.
Delegates to the shared ``get_fallback_chain`` helper so the TUI path
stays in parity with ``HermesCLI.__init__`` and ``gateway/run.py``:
``fallback_providers`` is the primary source of truth and keeps its
order, with legacy ``fallback_model`` entries merged in afterwards
(deduped on provider/model/base_url).
"""
from hermes_cli.fallback_config import get_fallback_chain
return get_fallback_chain(_load_cfg())
def _agent_fallback_model(agent):
"""Return an agent's fallback chain without rehydrating deliberately empty chains."""
if hasattr(agent, "_fallback_chain"):
return getattr(agent, "_fallback_chain") or []
if hasattr(agent, "_fallback_model"):
return getattr(agent, "_fallback_model", None)
return _load_fallback_model()
def _background_agent_kwargs(agent, task_id: str) -> dict:
cfg = _load_cfg()
@@ -2645,7 +2619,7 @@ def _background_agent_kwargs(agent, task_id: str) -> dict:
"request_overrides": dict(getattr(agent, "request_overrides", {}) or {}),
"platform": "tui",
"session_db": _get_db(),
"fallback_model": _agent_fallback_model(agent),
"fallback_model": getattr(agent, "_fallback_model", None),
}
@@ -2904,7 +2878,6 @@ def _make_agent(
pass_session_id=is_truthy_value(os.environ.get("HERMES_TUI_PASS_SESSION_ID")),
skip_context_files=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")),
skip_memory=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")),
fallback_model=_load_fallback_model(),
**_agent_cbs(sid),
)
@@ -5598,7 +5571,7 @@ def _(rid, params: dict) -> dict:
str(pdf_path), str(out_prefix),
]
try:
res = subprocess.run(argv, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
res = subprocess.run(argv, capture_output=True, text=True, timeout=120)
except subprocess.TimeoutExpired:
return _err(rid, 5028, "pdftoppm timed out (>120s)")
if res.returncode != 0:
@@ -5632,197 +5605,6 @@ def _(rid, params: dict) -> dict:
)
_ATTACHMENT_REF_NEEDS_QUOTING_RE = None
def _format_ref_value(value: str) -> str:
"""Quote a context-ref value when it contains whitespace or bracket chars.
Mirrors the desktop ``formatRefValue`` so the staged ``@file:`` ref round-trips
through ``agent.context_references`` cleanly.
"""
import re as _re
global _ATTACHMENT_REF_NEEDS_QUOTING_RE
if _ATTACHMENT_REF_NEEDS_QUOTING_RE is None:
_ATTACHMENT_REF_NEEDS_QUOTING_RE = _re.compile(r"""[\s()\[\]{}<>"'`]""")
if not value or not _ATTACHMENT_REF_NEEDS_QUOTING_RE.search(value):
return value
if "`" not in value:
return f"`{value}`"
if '"' not in value:
return f'"{value}"'
if "'" not in value:
return f"'{value}'"
return value
def _attachment_ref_path(session: dict, target: Path) -> str:
"""Workspace-relative path for an attachment, or the absolute path if outside."""
workspace = Path(_session_cwd(session)).resolve()
try:
rel = target.resolve().relative_to(workspace)
return str(rel).replace(os.sep, "/")
except ValueError:
return str(target.resolve())
def _desktop_attachment_dir(session: dict) -> Path:
root = Path(_session_cwd(session)).resolve() / ".hermes" / "desktop-attachments"
root.mkdir(parents=True, exist_ok=True)
return root
def _sanitize_attachment_name(name: str) -> str:
import re as _re
candidate = Path(str(name or "").strip()).name
candidate = _re.sub(r"[\x00-\x1f]+", "_", candidate)
candidate = candidate.strip().strip(".")
return candidate or "attachment"
def _unique_attachment_path(root: Path, filename: str) -> Path:
candidate = root / filename
if not candidate.exists():
return candidate
stem = Path(filename).stem or "attachment"
suffix = Path(filename).suffix
counter = 2
while True:
next_candidate = root / f"{stem}-{counter}{suffix}"
if not next_candidate.exists():
return next_candidate
counter += 1
def _resolve_gateway_attachment_path(raw: str) -> Path | None:
"""Resolve a raw path token to a gateway-visible file, or None."""
if not raw:
return None
try:
from cli import _detect_file_drop, _resolve_attachment_path, _split_path_input
except Exception:
return None
dropped = _detect_file_drop(raw)
if dropped:
return Path(dropped["path"]).resolve()
path_token, _remainder = _split_path_input(raw)
resolved = _resolve_attachment_path(path_token)
return Path(resolved).resolve() if resolved is not None else None
def _decode_attachment_data_url(data_url: str) -> bytes:
"""Decode a ``data:<any-mime>;base64,<b64>`` payload to bytes.
Unlike ``_decode_attach_base64`` (image-mime-specific), this accepts any
media type text/csv, application/pdf, etc. so non-image file uploads
round-trip. Also tolerates a bare base64 string with no data-URL prefix.
"""
import base64 as _base64
import binascii as _binascii
import re as _re
cleaned = (data_url or "").strip()
m = _re.match(r"^data:[^;,]*(?:;[^;,=]+=[^;,]+)*;base64,(.*)$", cleaned, _re.DOTALL | _re.I)
if m:
cleaned = m.group(1)
cleaned = _re.sub(r"\s+", "", cleaned)
try:
return _base64.b64decode(cleaned, validate=True)
except (ValueError, _binascii.Error) as exc:
raise ValueError("invalid data_url payload") from exc
def _stage_session_file_attachment(
session: dict,
*,
raw_path: str,
data_url: str,
name: str,
) -> tuple[Path, bool]:
"""Make a desktop file attachment available to the remote gateway agent.
Three cases:
1. The path resolves to a file already INSIDE the session workspace use
it as-is (no copy, ``uploaded=False``).
2. The path resolves to a gateway-visible file OUTSIDE the workspace copy
it into ``.hermes/desktop-attachments/`` so the ``@file:`` ref resolves.
3. The path doesn't exist on the gateway (the common remote case: it's a
path on the CLIENT's disk) — decode the uploaded ``data_url`` bytes and
write them into ``.hermes/desktop-attachments/``.
Returns ``(stored_path, uploaded)``.
"""
workspace = Path(_session_cwd(session)).resolve()
resolved = _resolve_gateway_attachment_path(raw_path)
if resolved is not None:
try:
resolved.relative_to(workspace)
return resolved, False
except ValueError:
payload = resolved.read_bytes()
filename = resolved.name
else:
if not data_url:
raise ValueError("file not found on gateway and no data_url provided")
payload = _decode_attachment_data_url(data_url)
filename = _sanitize_attachment_name(name or Path(str(raw_path or "")).name)
upload_dir = _desktop_attachment_dir(session)
target = _unique_attachment_path(upload_dir, _sanitize_attachment_name(filename))
target.write_bytes(payload)
return target.resolve(), True
@method("file.attach")
def _(rid, params: dict) -> dict:
"""Stage a non-image file attachment into the session workspace.
The image/PDF path renders to vision tiles; this one keeps the file as a
readable artifact and returns a workspace-relative ``@file:`` ref so the
agent's file tools (and ``agent.context_references``) can read it. Solves the
remote-gateway case where the desktop passes a path that only exists on the
CLIENT's disk: the client uploads ``data_url`` bytes and we materialize the
file on the gateway.
Params:
session_id (str, required)
path (str): client/host path of the file (used for naming + local-mode
gateway-visible resolution).
data_url (str): ``data:<mime>;base64,<b64>`` upload of the file bytes,
required when the path isn't visible to the gateway.
name (str, optional): preferred filename.
"""
session, err = _sess(params, rid)
if err:
return err
raw = str(params.get("path", "") or "").strip()
data_url = str(params.get("data_url", "") or "").strip()
name = str(params.get("name", "") or "").strip()
if not raw and not data_url:
return _err(rid, 4015, "path or data_url required")
try:
stored_path, uploaded = _stage_session_file_attachment(
session, raw_path=raw, data_url=data_url, name=name
)
ref_path = _attachment_ref_path(session, stored_path)
return _ok(
rid,
{
"attached": True,
"name": stored_path.name,
"path": str(stored_path),
"ref_path": ref_path,
"ref_text": f"@file:{_format_ref_value(ref_path)}",
"uploaded": uploaded,
},
)
except Exception as e:
return _err(rid, 5028, str(e))
@method("image.detach")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
@@ -7063,7 +6845,6 @@ def _(rid, params: dict) -> dict:
timeout=min(int(params.get("timeout", 240)), 600),
cwd=os.getcwd(),
env=os.environ.copy(),
stdin=subprocess.DEVNULL,
)
parts = [r.stdout or "", r.stderr or ""]
out = "\n".join(p for p in parts if p).strip() or "(no output)"
@@ -7124,7 +6905,6 @@ def _(rid, params: dict) -> dict:
capture_output=True,
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
)
output = (
(r.stdout or "")
@@ -7515,7 +7295,6 @@ def _list_repo_files(root: str) -> list[str]:
capture_output=True,
timeout=2.0,
check=False,
stdin=subprocess.DEVNULL,
)
if top_result.returncode == 0:
top = top_result.stdout.decode("utf-8", "replace").strip()
@@ -7533,7 +7312,6 @@ def _list_repo_files(root: str) -> list[str]:
capture_output=True,
timeout=2.0,
check=False,
stdin=subprocess.DEVNULL,
)
if list_result.returncode == 0:
for p in list_result.stdout.decode("utf-8", "replace").split("\0"):
@@ -9267,8 +9045,7 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5001, "shell.exec unavailable: approval safety module not importable")
try:
r = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd(),
stdin=subprocess.DEVNULL,
cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd()
)
return _ok(
rid,
+1 -7
View File
@@ -162,10 +162,7 @@ Send an iMessage to your assigned number and Hermes will reply.
hermes photon status
```
Prints saved credentials, sidecar health, your registered number, and the
assigned iMessage line Hermes uses. When a Photon token and dashboard project
are available, `status` refreshes missing number rows from the dashboard
without provisioning new lines.
Prints:
```
Photon iMessage status
@@ -174,8 +171,6 @@ Photon iMessage status
dashboard project : 3c90c3cc-0d44-4b50-...
spectrum project id : sp-...
project secret : ✓ stored
my number : +15551234567
assigned number : +16282679185
node binary : /usr/bin/node
sidecar deps : ✓ installed
```
@@ -222,7 +217,6 @@ Common issues:
| `PHOTON_REQUIRE_MENTION` | `false` | Require a wake word before responding in groups |
| `PHOTON_MENTION_PATTERNS` | Hermes wake words | JSON list / comma / newline regex patterns for group mentions |
| `PHOTON_DASHBOARD_HOST` | `app.photon.codes` | Override the dashboard / device-login host |
| `PHOTON_SPECTRUM_HOST` | `spectrum.photon.codes` | Override the Spectrum API host |
[photon]: https://photon.codes/
[app]: https://app.photon.codes/
+1 -9
View File
@@ -1,6 +1,6 @@
{
"version": 1,
"updated_at": "2026-06-09T05:57:24Z",
"updated_at": "2026-06-04T23:57:51Z",
"metadata": {
"source": "hermes-agent repo",
"docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog"
@@ -116,10 +116,6 @@
"id": "openrouter/owl-alpha",
"description": "free"
},
{
"id": "poolside/laguna-m.1:free",
"description": "free"
},
{
"id": "tencent/hy3-preview:free",
"description": "free"
@@ -128,10 +124,6 @@
"id": "nvidia/nemotron-3-super-120b-a12b:free",
"description": "free"
},
{
"id": "nvidia/nemotron-3-ultra-550b-a55b:free",
"description": "free"
},
{
"id": "inclusionai/ring-2.6-1t:free",
"description": "free"