Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea0efea2bd |
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}]"
|
||||
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -520,7 +520,6 @@ class VoiceReceiver:
|
||||
],
|
||||
check=True,
|
||||
timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: []
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()}"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -472,7 +472,6 @@ class ProcessRegistry:
|
||||
text=True,
|
||||
timeout=10,
|
||||
creationflags=windows_hide_flags(),
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
Reference in New Issue
Block a user