Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df9061743d | ||
|
|
f8adefdebf | ||
|
|
dbbd1d4d05 | ||
|
|
e687292eb4 | ||
|
|
c4066091ca | ||
|
|
50ad191a8b | ||
|
|
520b59db16 | ||
|
|
4b073d0906 | ||
|
|
dbf2470d46 | ||
|
|
9fb83eaa2f | ||
|
|
0337658904 | ||
|
|
b58ff93459 | ||
|
|
2130ef68b3 | ||
|
|
637cf94bed | ||
|
|
9351cbafab | ||
|
|
18ead88273 | ||
|
|
dba6380ca6 | ||
|
|
ba622d44e4 | ||
|
|
2c1aaa9cba | ||
|
|
8bb60ff039 | ||
|
|
bddab61bcb | ||
|
|
d1f23bb2d5 | ||
|
|
54318c65b0 | ||
|
|
c1927d2342 | ||
|
|
3705625b74 |
@@ -821,6 +821,7 @@ 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")
|
||||
@@ -1163,7 +1164,10 @@ def run_oauth_setup_token() -> Optional[str]:
|
||||
"Install it with: npm install -g @anthropic-ai/claude-code"
|
||||
)
|
||||
|
||||
# Run interactively — stdin/stdout/stderr inherited so user can interact
|
||||
# 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
|
||||
try:
|
||||
subprocess.run([claude_path, "setup-token"])
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
|
||||
@@ -290,6 +290,7 @@ 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
|
||||
@@ -482,6 +483,7 @@ 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,6 +262,7 @@ def _install_npm(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
@@ -310,6 +311,7 @@ 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(
|
||||
@@ -347,6 +349,7 @@ 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,6 +274,7 @@ 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"
|
||||
@@ -525,6 +526,7 @@ def _run_bws_list(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_BWS_RUN_TIMEOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(
|
||||
|
||||
@@ -74,6 +74,7 @@ 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,6 +378,7 @@ def check_codex_binary(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return False, (
|
||||
|
||||
@@ -4,6 +4,8 @@ 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'
|
||||
@@ -42,7 +44,10 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
|
||||
interface HarnessHandle {
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
|
||||
submitText: (
|
||||
text: string,
|
||||
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
function Harness({
|
||||
@@ -314,3 +319,92 @@ 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,6 +47,7 @@ import {
|
||||
|
||||
import type {
|
||||
ClientSessionState,
|
||||
FileAttachResponse,
|
||||
ImageAttachResponse,
|
||||
SessionSteerResponse,
|
||||
SessionTitleResponse,
|
||||
@@ -103,6 +104,20 @@ 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>
|
||||
@@ -212,62 +227,114 @@ export function usePromptActions({
|
||||
[selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
const syncImageAttachmentsForSubmit = useCallback(
|
||||
const syncAttachmentsForSubmit = 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 images) {
|
||||
if (attachment.attachedSessionId === sessionId) {
|
||||
for (const attachment of attachments) {
|
||||
// Already-synced or pathless refs (terminal, url, etc.) pass through.
|
||||
if (!attachment.path || attachment.attachedSessionId === sessionId) {
|
||||
synced.push(attachment)
|
||||
continue
|
||||
}
|
||||
|
||||
let result: ImageAttachResponse
|
||||
if (attachment.kind === 'image') {
|
||||
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 = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
|
||||
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 (!payload) {
|
||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
||||
throw new Error(`Could not read ${label}`)
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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 (!result.attached) {
|
||||
const label = attachment.label || pathLabel(attachment.path)
|
||||
throw new Error(result.message || `Could not attach ${label}`)
|
||||
}
|
||||
|
||||
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({
|
||||
const attachedPath = result.path || attachment.path
|
||||
const nextAttachment: ComposerAttachment = {
|
||||
...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]
|
||||
)
|
||||
@@ -278,35 +345,42 @@ 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))
|
||||
|
||||
const text =
|
||||
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
|
||||
(hasImage ? 'What do you see in this image?' : '')
|
||||
// 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?' : '')
|
||||
)
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (!text || (!options?.fromQueue && busyRef.current)) {
|
||||
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
|
||||
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
const buildUserMessage = (): ChatMessage => ({
|
||||
id: optimisticId,
|
||||
role: 'user',
|
||||
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
|
||||
attachmentRefs
|
||||
}
|
||||
})
|
||||
|
||||
const releaseBusy = () => {
|
||||
setMutableRef(busyRef, false)
|
||||
@@ -323,7 +397,7 @@ export function usePromptActions({
|
||||
...state,
|
||||
messages: state.messages.some(m => m.id === optimisticId)
|
||||
? state.messages
|
||||
: [...state.messages, userMessage],
|
||||
: [...state.messages, buildUserMessage()],
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
@@ -336,6 +410,18 @@ 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))
|
||||
@@ -366,7 +452,7 @@ export function usePromptActions({
|
||||
if (sessionId) {
|
||||
seedOptimistic(sessionId)
|
||||
} else {
|
||||
setMessages(current => [...current, userMessage])
|
||||
setMessages(current => [...current, buildUserMessage()])
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -392,9 +478,14 @@ export function usePromptActions({
|
||||
}
|
||||
|
||||
try {
|
||||
await syncImageAttachmentsForSubmit(sessionId, attachments, {
|
||||
const syncedAttachments = await syncAttachmentsForSubmit(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) {
|
||||
@@ -442,7 +533,7 @@ export function usePromptActions({
|
||||
createBackendSessionForSend,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
syncImageAttachmentsForSubmit,
|
||||
syncAttachmentsForSubmit,
|
||||
updateSessionState
|
||||
]
|
||||
)
|
||||
|
||||
@@ -27,6 +27,20 @@ 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,7 +88,8 @@ 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.
|
||||
const REQUIRED_BACKEND_CONTRACT = 1
|
||||
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
|
||||
const REQUIRED_BACKEND_CONTRACT = 2
|
||||
const SKEW_TOAST_ID = 'backend-contract-skew'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
@@ -1797,9 +1797,10 @@ 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.
|
||||
# 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).
|
||||
# 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).
|
||||
supports_code_blocks: bool = False
|
||||
|
||||
def __init__(self, config: PlatformConfig, platform: Platform):
|
||||
|
||||
+61
-4
@@ -688,7 +688,18 @@ 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"}
|
||||
_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")
|
||||
|
||||
|
||||
# Extension-anchored MEDIA: matcher for tool results. Mirrors the dispatch-site
|
||||
@@ -755,10 +766,28 @@ 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:
|
||||
@@ -12971,9 +13000,33 @@ 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()
|
||||
@@ -12994,7 +13047,11 @@ 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).
|
||||
if preview:
|
||||
# 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:
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
_cap = _pl if _pl > 0 else 40
|
||||
|
||||
+92
-30
@@ -13,6 +13,7 @@ This module provides:
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
@@ -5152,6 +5153,94 @@ 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()
|
||||
@@ -6040,36 +6129,9 @@ 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.
|
||||
_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))
|
||||
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))
|
||||
|
||||
print(f"✓ Set {key} = {value} in {config_path}")
|
||||
|
||||
|
||||
@@ -1825,6 +1825,11 @@ 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"
|
||||
)
|
||||
@@ -5806,6 +5811,16 @@ 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:
|
||||
@@ -8365,6 +8380,22 @@ 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,6 +356,37 @@ 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
|
||||
|
||||
+85
-5
@@ -73,8 +73,10 @@ 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"),
|
||||
]
|
||||
|
||||
@@ -764,6 +766,64 @@ _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,
|
||||
@@ -774,12 +834,19 @@ 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; pass ``force_refresh=True`` to
|
||||
bypass the cache.
|
||||
``_NOUS_RECOMMENDED_CACHE_TTL`` seconds in process; pass
|
||||
``force_refresh=True`` to bypass the in-process cache.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
|
||||
now = time.monotonic()
|
||||
@@ -802,6 +869,19 @@ 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,6 +8573,11 @@ 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,6 +86,7 @@ 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.
|
||||
@@ -111,6 +112,7 @@ class AudioBridge:
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
@@ -135,6 +137,7 @@ 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.
|
||||
@@ -142,6 +145,7 @@ 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,6 +94,7 @@ 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,6 +695,7 @@ 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:
|
||||
@@ -1101,6 +1102,7 @@ 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,6 +416,7 @@ 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,6 +628,7 @@ 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,6 +520,7 @@ class VoiceReceiver:
|
||||
],
|
||||
check=True,
|
||||
timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -320,6 +320,7 @@ 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,6 +111,7 @@ 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 |
|
||||
@@ -118,14 +119,14 @@ All env vars are documented in `plugin.yaml`. The most important:
|
||||
|
||||
## Attachments & limitations
|
||||
|
||||
- **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
|
||||
- **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
|
||||
`PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or any byte read that
|
||||
fails, fall back to a text marker (`[Photon attachment received: …]`) so the
|
||||
agent still knows something arrived.
|
||||
fails, falls back to a text marker (`[Photon attachment received: …]` or
|
||||
`[Photon voice 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,6 +60,7 @@ from gateway.platforms.base import (
|
||||
MessageType,
|
||||
SendResult,
|
||||
)
|
||||
from gateway.platforms.helpers import strip_markdown
|
||||
|
||||
from .auth import load_project_credentials
|
||||
|
||||
@@ -434,13 +435,15 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
"space": {"id": "...", "type": "dm"|"group", "phone": "+E164"},
|
||||
"sender": {"id": "+E164"},
|
||||
"content": {"type": "text", "text": "..."}
|
||||
| {"type": "attachment", "id", "name", "mimeType",
|
||||
"size", "data"?, "encoding"?},
|
||||
| {"type": "attachment"|"voice", "id", "name",
|
||||
"mimeType", "size", "duration"?, "data"?,
|
||||
"encoding"?},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z"
|
||||
|
||||
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.
|
||||
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.
|
||||
}
|
||||
"""
|
||||
space = event.get("space") or {}
|
||||
@@ -475,23 +478,38 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
if ctype == "text":
|
||||
text = content.get("text") or ""
|
||||
mtype = MessageType.TEXT
|
||||
elif ctype == "attachment":
|
||||
name = content.get("name") or "(unnamed)"
|
||||
elif ctype in {"attachment", "voice"}:
|
||||
is_voice = ctype == "voice"
|
||||
name = content.get("name") or ("voice" if is_voice else "(unnamed)")
|
||||
mime = content.get("mimeType") or ""
|
||||
mtype = _attachment_message_type(mime)
|
||||
cached = _cache_inbound_attachment(content, name, mime)
|
||||
mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime)
|
||||
cached = _cache_inbound_attachment(
|
||||
content, name, mime, force_audio=is_voice
|
||||
)
|
||||
if cached:
|
||||
media_urls.append(cached)
|
||||
media_types.append(mime or "application/octet-stream")
|
||||
media_types.append(
|
||||
mime or ("audio/mp4" if is_voice else "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 = "(attachment)"
|
||||
text = "(voice)" if is_voice else "(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.
|
||||
text = f"[Photon attachment received: {name} ({mime})]"
|
||||
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})]"
|
||||
)
|
||||
else:
|
||||
text = f"[Photon content type not handled: {ctype}]"
|
||||
mtype = MessageType.TEXT
|
||||
@@ -640,7 +658,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
return await self._sidecar_send(chat_id, content)
|
||||
return await self._sidecar_send(chat_id, self.format_message(content))
|
||||
|
||||
# -- Outbound media (parity with the BlueBubbles iMessage channel) -----
|
||||
#
|
||||
@@ -759,6 +777,74 @@ 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(
|
||||
@@ -881,7 +967,11 @@ _AUDIO_EXT_BY_MIME = {
|
||||
|
||||
|
||||
def _cache_inbound_attachment(
|
||||
content: Dict[str, Any], name: str, mime: str
|
||||
content: Dict[str, Any],
|
||||
name: str,
|
||||
mime: str,
|
||||
*,
|
||||
force_audio: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Decode a base64-inlined inbound attachment and cache it locally.
|
||||
|
||||
@@ -919,8 +1009,10 @@ 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 mime.startswith("audio/"):
|
||||
ext = suffix or _AUDIO_EXT_BY_MIME.get(mime, ".mp3")
|
||||
if force_audio or mime.startswith("audio/"):
|
||||
ext = suffix or _AUDIO_EXT_BY_MIME.get(
|
||||
mime, ".m4a" if force_audio else ".mp3"
|
||||
)
|
||||
return cache_audio_from_bytes(raw, ext)
|
||||
# Video, application/*, and everything else → document cache.
|
||||
return cache_document_from_bytes(raw, name)
|
||||
|
||||
@@ -27,8 +27,9 @@ 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) and
|
||||
``credential_pool.photon_project`` (dashboard id, spectrum id, name)
|
||||
``credential_pool.photon`` (device token),
|
||||
``credential_pool.photon_project`` (dashboard id, spectrum id, name), and
|
||||
``credential_pool.photon_user`` (operator number + assigned text line)
|
||||
|
||||
Reference: https://github.com/photon-hq/cli and
|
||||
https://photon.codes/docs/api-reference/device-login/request-device-+-user-code
|
||||
@@ -40,6 +41,7 @@ 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
|
||||
@@ -67,6 +69,7 @@ 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"
|
||||
@@ -205,6 +208,30 @@ 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).
|
||||
|
||||
@@ -248,10 +275,43 @@ 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:
|
||||
@@ -559,6 +619,11 @@ 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 []
|
||||
|
||||
|
||||
@@ -662,37 +727,37 @@ def regenerate_project_secret(token: str, project_id: str) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard API: spectrum users
|
||||
# Spectrum API: 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(token: str, project_id: str) -> List[Dict[str, Any]]:
|
||||
"""GET ``/api/projects/{id}/spectrum/users`` → ``SpectrumUser[]``."""
|
||||
def list_users(project_id: str, project_secret: str) -> List[Dict[str, Any]]:
|
||||
"""GET Spectrum Cloud ``/projects/{id}/users/`` → ``SpectrumUser[]``."""
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon")
|
||||
url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/users"
|
||||
resp = httpx.get(url, headers=_bearer(token), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
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")
|
||||
return _unwrap_list(resp.json())
|
||||
|
||||
|
||||
def find_user_by_phone(
|
||||
token: str, project_id: str, phone_number: str,
|
||||
project_id: str, project_secret: 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(token, project_id):
|
||||
for user in list_users(project_id, project_secret):
|
||||
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,
|
||||
@@ -700,32 +765,42 @@ def create_user(
|
||||
email: Optional[str] = None,
|
||||
send_invite: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""POST ``/api/projects/{id}/spectrum/users`` and return the created user."""
|
||||
"""POST Spectrum Cloud ``/projects/{id}/users/`` and return the 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"{_dashboard_host()}/api/projects/{project_id}/spectrum/users"
|
||||
body: Dict[str, Any] = {"phoneNumber": phone_number, "sendInvite": send_invite}
|
||||
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")
|
||||
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=_bearer(token), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json=body,
|
||||
headers=_basic(project_id, project_secret),
|
||||
timeout=30.0,
|
||||
)
|
||||
_raise_for_status(resp, "create-user")
|
||||
data = resp.json() or {}
|
||||
if data.get("error"):
|
||||
raise RuntimeError(f"Photon create-user failed: {data['error']}")
|
||||
return data.get("user") or data
|
||||
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")
|
||||
|
||||
|
||||
def register_user_if_absent(
|
||||
token: str,
|
||||
project_id: str,
|
||||
project_secret: str,
|
||||
*,
|
||||
phone_number: str,
|
||||
first_name: Optional[str] = None,
|
||||
@@ -738,11 +813,12 @@ 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(token, project_id, phone_number)
|
||||
existing = find_user_by_phone(project_id, project_secret, phone_number)
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
user = create_user(
|
||||
token, project_id,
|
||||
project_id,
|
||||
project_secret,
|
||||
phone_number=phone_number,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
@@ -766,6 +842,104 @@ 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)
|
||||
|
||||
@@ -836,6 +1010,13 @@ 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",
|
||||
@@ -844,6 +1025,8 @@ 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))
|
||||
|
||||
@@ -864,9 +1047,19 @@ 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,6 +183,8 @@ 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:
|
||||
@@ -192,7 +194,7 @@ def _cmd_setup(args: argparse.Namespace) -> int:
|
||||
email = args.email
|
||||
try:
|
||||
user, created = photon_auth.register_user_if_absent(
|
||||
token, dashboard_id,
|
||||
spectrum_id, secret,
|
||||
phone_number=phone,
|
||||
first_name=first_name,
|
||||
last_name=args.last_name,
|
||||
@@ -205,6 +207,8 @@ 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
|
||||
@@ -236,6 +240,16 @@ 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:
|
||||
@@ -280,6 +294,7 @@ 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.
|
||||
@@ -291,6 +306,19 @@ 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()
|
||||
|
||||
@@ -304,9 +332,13 @@ def _install_sidecar() -> int:
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(f" $ cd {_SIDECAR_DIR} && {npm} install")
|
||||
# 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")
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[npm, "install"],
|
||||
[npm, "install", "spectrum-ts@latest"],
|
||||
cwd=str(_SIDECAR_DIR),
|
||||
check=False,
|
||||
)
|
||||
|
||||
@@ -46,6 +46,10 @@ 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 attachments are read into memory and base64-inlined on the NDJSON
|
||||
// Inbound binary content is read into memory and base64-inlined on the NDJSON
|
||||
// event so the Python adapter can cache the real bytes (and the agent can see
|
||||
// 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.
|
||||
// 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.
|
||||
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,6 +92,7 @@ const app = await Spectrum({
|
||||
projectId,
|
||||
projectSecret,
|
||||
providers: [imessage.config()],
|
||||
options: { flattenGroups: true },
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -163,6 +164,57 @@ 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" };
|
||||
@@ -170,51 +222,8 @@ async function normalizeContent(content) {
|
||||
if (content.type === "text") {
|
||||
return { type: "text", text: content.text || "" };
|
||||
}
|
||||
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;
|
||||
if (content.type === "attachment" || content.type === "voice") {
|
||||
return await normalizeBinaryContent(content);
|
||||
}
|
||||
return { type: content.type || "unknown" };
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"name": "@hermes-agent/photon-sidecar",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"spectrum-ts": "^1.17.1"
|
||||
"spectrum-ts": "^1.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"spectrum-ts": "^1.17.1"
|
||||
"spectrum-ts": "^1.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/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,12 +58,14 @@ 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,7 +159,106 @@ 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_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."""
|
||||
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)."""
|
||||
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
||||
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
@@ -1328,18 +1328,20 @@ async def test_terminal_progress_is_truncated_preview_not_bash_block(monkeypatch
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="sess-terminal-no-bash-block",
|
||||
session_id="sess-terminal-code-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)
|
||||
# Compact truncated preview, not a fenced bash block.
|
||||
# Bare fenced block, no language tag (no '```bash').
|
||||
assert "```" in all_content
|
||||
assert "```bash" 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
|
||||
# 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -21,26 +23,42 @@ def all_assignees_spawnable(monkeypatch):
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _suppress_concurrent_hermes_gate(request, monkeypatch):
|
||||
"""Default ``_detect_concurrent_hermes_instances`` to ``[]`` for every test.
|
||||
"""Default ``_detect_concurrent_hermes_instances`` to ``[]`` on Windows hosts.
|
||||
|
||||
The Windows update path now refuses to proceed when another
|
||||
``hermes.exe`` is detected (issue #26670). On a developer's Windows
|
||||
machine running the test suite via ``hermes`` itself, this would
|
||||
flag the running agent as a concurrent instance and abort every
|
||||
``cmd_update`` test. Tests that want to exercise the gate explicitly
|
||||
re-patch ``_detect_concurrent_hermes_instances`` with their own
|
||||
return value — autouse here gives a clean default without touching
|
||||
the rest of the suite.
|
||||
The Windows update path refuses to proceed when another ``hermes.exe`` is
|
||||
detected (issue #26670). On a developer's Windows machine running the test
|
||||
suite via ``hermes`` itself, this would flag the running agent as a
|
||||
concurrent instance and abort every ``cmd_update`` test. This fixture
|
||||
stubs the helper to ``[]`` so those tests run cleanly.
|
||||
|
||||
Tests that need to call the REAL function (e.g. unit tests for the
|
||||
helper itself) opt out with ``@pytest.mark.real_concurrent_gate``.
|
||||
Scope: the helper short-circuits to ``[]`` via ``not _is_windows()`` on
|
||||
every non-Windows host (Linux CI, macOS), so there is nothing to suppress
|
||||
there — and importing + monkeypatching ``hermes_cli.main`` for every test
|
||||
in the package is exactly what raced a partially-initialized module under
|
||||
pytest's per-test spawn isolation (the AttributeError flake). Gating the
|
||||
whole fixture behind ``sys.platform == "win32"`` means CI never imports or
|
||||
mutates ``main`` here, removing the race at its source while preserving the
|
||||
Windows-dev behavior the fixture exists for.
|
||||
|
||||
Tests that need to call the REAL function (e.g. unit tests for the helper
|
||||
itself, or that force ``_is_windows`` True) opt out with
|
||||
``@pytest.mark.real_concurrent_gate``.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
if request.node.get_closest_marker("real_concurrent_gate"):
|
||||
return
|
||||
try:
|
||||
from hermes_cli import main as _cli_main
|
||||
except Exception:
|
||||
return
|
||||
# raising=False: defense-in-depth against a transiently partial
|
||||
# hermes_cli.main module under spawn isolation. The attribute always
|
||||
# exists once main.py finishes importing, so a no-op when it's briefly
|
||||
# absent is the correct, race-free default.
|
||||
monkeypatch.setattr(
|
||||
_cli_main, "_detect_concurrent_hermes_instances", lambda *_a, **_k: []
|
||||
_cli_main,
|
||||
"_detect_concurrent_hermes_instances",
|
||||
lambda *_a, **_k: [],
|
||||
raising=False,
|
||||
)
|
||||
|
||||
@@ -896,6 +896,46 @@ 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,6 +4146,39 @@ 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,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
@@ -40,6 +41,9 @@ _PHOTON_ENV = (
|
||||
"PHOTON_PROJECT_ID",
|
||||
"PHOTON_PROJECT_SECRET",
|
||||
"PHOTON_DASHBOARD_PROJECT_ID",
|
||||
"PHOTON_SPECTRUM_HOST",
|
||||
"PHOTON_ALLOWED_USERS",
|
||||
"PHOTON_HOME_CHANNEL",
|
||||
)
|
||||
|
||||
|
||||
@@ -98,6 +102,64 @@ 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:
|
||||
@@ -303,7 +365,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("tok", "proj", phone_number="not-a-number")
|
||||
photon_auth.create_user("proj", "secret", phone_number="not-a-number")
|
||||
|
||||
|
||||
def test_create_user_posts_dashboard_shape(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -313,27 +375,30 @@ 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={"success": True, "user": {
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {
|
||||
"id": "user-uuid", "phoneNumber": "+15551234567",
|
||||
}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
user = photon_auth.create_user("tok", "proj-id", phone_number="+15551234567")
|
||||
user = photon_auth.create_user("proj-id", "secret", phone_number="+15551234567")
|
||||
assert user["id"] == "user-uuid"
|
||||
assert captured["body"]["type"] == "shared"
|
||||
assert captured["body"]["phoneNumber"] == "+15551234567"
|
||||
assert captured["headers"]["Authorization"] == "Bearer tok"
|
||||
assert "/projects/proj-id/spectrum/users" in captured["url"]
|
||||
assert captured["headers"]["Authorization"] == (
|
||||
"Basic " + b64encode(b"proj-id:secret").decode("ascii")
|
||||
)
|
||||
assert captured["url"].endswith("/projects/proj-id/users/")
|
||||
|
||||
|
||||
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=[{
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
|
||||
"id": "u1",
|
||||
"phoneNumber": "+1 (555) 123-4567",
|
||||
"assignedPhoneNumber": "+16282679185",
|
||||
}])
|
||||
}]}})
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
posted["n"] += 1
|
||||
@@ -343,7 +408,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(
|
||||
"tok", "proj", phone_number="+15551234567",
|
||||
"proj", "secret", phone_number="+15551234567",
|
||||
)
|
||||
assert created is False
|
||||
assert user["id"] == "u1"
|
||||
@@ -366,15 +431,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=[])
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"users": []}})
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"success": True, "user": {"id": "u-new"}})
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"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(
|
||||
"tok", "proj", phone_number="+15551234567",
|
||||
"proj", "secret", phone_number="+15551234567",
|
||||
)
|
||||
assert created is True
|
||||
assert user["id"] == "u-new"
|
||||
@@ -435,6 +500,8 @@ 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,6 +101,18 @@ 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,
|
||||
@@ -156,6 +168,64 @@ 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,6 +902,115 @@ 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)
|
||||
@@ -2805,6 +2914,164 @@ 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,6 +109,7 @@ def test_ensure_docker_available_uses_resolved_executable(monkeypatch):
|
||||
"capture_output": True,
|
||||
"text": True,
|
||||
"timeout": 5,
|
||||
"stdin": subprocess.DEVNULL,
|
||||
})
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""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"
|
||||
|
||||
@@ -307,6 +307,7 @@ def _run_git(
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
cwd=str(normalized_working_dir),
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
ok = result.returncode == 0
|
||||
stdout = result.stdout.strip()
|
||||
@@ -426,6 +427,7 @@ 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,6 +1618,7 @@ 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,6 +65,7 @@ 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,6 +177,7 @@ 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)
|
||||
@@ -210,6 +211,7 @@ 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
|
||||
@@ -239,6 +241,7 @@ 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)
|
||||
@@ -381,6 +384,7 @@ 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)
|
||||
@@ -453,6 +457,7 @@ def _ensure_docker_available() -> None:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
@@ -833,6 +838,7 @@ class DockerEnvironment(BaseEnvironment):
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
logger.warning(
|
||||
@@ -871,6 +877,7 @@ 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`
|
||||
@@ -887,6 +894,7 @@ 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()
|
||||
@@ -997,6 +1005,7 @@ 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])
|
||||
@@ -1027,6 +1036,7 @@ 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
|
||||
@@ -1081,6 +1091,7 @@ 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":
|
||||
@@ -1091,13 +1102,15 @@ 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)
|
||||
capture_output=True, timeout=5,
|
||||
stdin=subprocess.DEVNULL)
|
||||
_storage_opt_ok = True
|
||||
else:
|
||||
_storage_opt_ok = False
|
||||
@@ -1132,6 +1145,7 @@ 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)
|
||||
@@ -1248,6 +1262,7 @@ 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)
|
||||
@@ -1256,6 +1271,7 @@ 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,6 +46,7 @@ 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(
|
||||
@@ -136,6 +137,7 @@ 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")
|
||||
@@ -218,7 +220,7 @@ class SingularityEnvironment(BaseEnvironment):
|
||||
cmd.extend([str(self.image), self.instance_id])
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to start instance: {result.stderr}")
|
||||
self._instance_started = True
|
||||
@@ -250,6 +252,7 @@ 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,6 +365,7 @@ 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 "")
|
||||
@@ -378,6 +379,7 @@ 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")
|
||||
@@ -386,6 +388,7 @@ 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, "",
|
||||
@@ -395,6 +398,7 @@ 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,6 +472,7 @@ class ProcessRegistry:
|
||||
text=True,
|
||||
timeout=10,
|
||||
creationflags=windows_hide_flags(),
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
|
||||
@@ -301,6 +301,7 @@ 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)
|
||||
result = subprocess.run([docker, "version"], capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
|
||||
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)
|
||||
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
|
||||
return result.returncode == 0
|
||||
return False
|
||||
|
||||
|
||||
@@ -288,6 +288,7 @@ 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")
|
||||
@@ -734,6 +735,7 @@ 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,6 +490,7 @@ 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()
|
||||
@@ -555,7 +556,7 @@ def _run_command_stt(command: str, timeout: float) -> subprocess.CompletedProces
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
proc = subprocess.Popen(command, **popen_kwargs)
|
||||
proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
@@ -1186,7 +1187,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)
|
||||
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
|
||||
return converted_path, None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("ffmpeg conversion timed out for %s", file_path)
|
||||
@@ -1232,9 +1233,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)
|
||||
subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
|
||||
else:
|
||||
subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300)
|
||||
subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
|
||||
|
||||
|
||||
txt_files = sorted(Path(output_dir).glob("*.txt"))
|
||||
|
||||
+9
-6
@@ -693,6 +693,7 @@ 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()
|
||||
@@ -745,7 +746,7 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
proc = subprocess.Popen(command, **popen_kwargs)
|
||||
proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
@@ -882,6 +883,7 @@ 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",
|
||||
@@ -1504,7 +1506,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)
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="ignore")[:300]
|
||||
raise RuntimeError(f"ffmpeg conversion failed: {stderr}")
|
||||
@@ -1587,7 +1589,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)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
# Filter out the "OK:" line from stderr
|
||||
@@ -1599,7 +1601,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)
|
||||
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
|
||||
os.remove(wav_path)
|
||||
else:
|
||||
# No ffmpeg — just rename the WAV to the expected path
|
||||
@@ -1670,6 +1672,7 @@ 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(
|
||||
@@ -1757,7 +1760,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)
|
||||
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
|
||||
try:
|
||||
os.remove(wav_path)
|
||||
except OSError:
|
||||
@@ -1823,7 +1826,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)
|
||||
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
|
||||
os.remove(wav_path)
|
||||
else:
|
||||
# No ffmpeg — rename the WAV to the expected path
|
||||
|
||||
+4
-3
@@ -75,6 +75,7 @@ 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:
|
||||
@@ -388,7 +389,7 @@ class TermuxAudioRecorder:
|
||||
"-c", str(CHANNELS),
|
||||
]
|
||||
try:
|
||||
subprocess.run(command, capture_output=True, text=True, timeout=15, check=True)
|
||||
subprocess.run(command, capture_output=True, text=True, timeout=15, check=True, stdin=subprocess.DEVNULL)
|
||||
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
|
||||
@@ -405,7 +406,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)
|
||||
subprocess.run([mic_cmd, "-q"], capture_output=True, text=True, timeout=15, check=False, stdin=subprocess.DEVNULL)
|
||||
|
||||
def stop(self) -> Optional[str]:
|
||||
with self._lock:
|
||||
@@ -1095,7 +1096,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)
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
|
||||
with _playback_lock:
|
||||
_active_playback = proc
|
||||
proc.wait(timeout=300)
|
||||
|
||||
+227
-4
@@ -1041,6 +1041,7 @@ 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()
|
||||
@@ -1052,6 +1053,7 @@ 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:
|
||||
@@ -1990,7 +1992,8 @@ 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.
|
||||
DESKTOP_BACKEND_CONTRACT = 1
|
||||
# v2: adds the file.attach RPC (remote-gateway non-image file upload).
|
||||
DESKTOP_BACKEND_CONTRACT = 2
|
||||
|
||||
|
||||
def _session_info(agent, session: dict | None = None) -> dict:
|
||||
@@ -2585,6 +2588,29 @@ 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()
|
||||
|
||||
@@ -2619,7 +2645,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": getattr(agent, "_fallback_model", None),
|
||||
"fallback_model": _agent_fallback_model(agent),
|
||||
}
|
||||
|
||||
|
||||
@@ -2878,6 +2904,7 @@ 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),
|
||||
)
|
||||
|
||||
@@ -5571,7 +5598,7 @@ def _(rid, params: dict) -> dict:
|
||||
str(pdf_path), str(out_prefix),
|
||||
]
|
||||
try:
|
||||
res = subprocess.run(argv, capture_output=True, text=True, timeout=120)
|
||||
res = subprocess.run(argv, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
|
||||
except subprocess.TimeoutExpired:
|
||||
return _err(rid, 5028, "pdftoppm timed out (>120s)")
|
||||
if res.returncode != 0:
|
||||
@@ -5605,6 +5632,197 @@ 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)
|
||||
@@ -6845,6 +7063,7 @@ 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)"
|
||||
@@ -6905,6 +7124,7 @@ def _(rid, params: dict) -> dict:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
output = (
|
||||
(r.stdout or "")
|
||||
@@ -7295,6 +7515,7 @@ 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()
|
||||
@@ -7312,6 +7533,7 @@ 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"):
|
||||
@@ -9045,7 +9267,8 @@ 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()
|
||||
cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd(),
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
return _ok(
|
||||
rid,
|
||||
|
||||
@@ -162,7 +162,10 @@ Send an iMessage to your assigned number and Hermes will reply.
|
||||
hermes photon status
|
||||
```
|
||||
|
||||
Prints:
|
||||
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.
|
||||
|
||||
```
|
||||
Photon iMessage status
|
||||
@@ -171,6 +174,8 @@ 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
|
||||
```
|
||||
@@ -217,6 +222,7 @@ 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-04T23:57:51Z",
|
||||
"updated_at": "2026-06-09T05:57:24Z",
|
||||
"metadata": {
|
||||
"source": "hermes-agent repo",
|
||||
"docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog"
|
||||
@@ -116,6 +116,10 @@
|
||||
"id": "openrouter/owl-alpha",
|
||||
"description": "free"
|
||||
},
|
||||
{
|
||||
"id": "poolside/laguna-m.1:free",
|
||||
"description": "free"
|
||||
},
|
||||
{
|
||||
"id": "tencent/hy3-preview:free",
|
||||
"description": "free"
|
||||
@@ -124,6 +128,10 @@
|
||||
"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