Merge branch 'main' into fix/nemo-relay-adaptive-config-shape

This commit is contained in:
kshitij 2026-06-08 14:42:05 -07:00 committed by GitHub
commit 1db79bfe1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 5968 additions and 262 deletions

View file

@ -53,7 +53,7 @@ If you already have Git installed, the installer detects it and uses that instea
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
>
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux.
After installation:

View file

@ -169,6 +169,7 @@ def init_agent(
save_trajectories: bool = False,
verbose_logging: bool = False,
quiet_mode: bool = False,
tool_progress_mode: str = "all",
ephemeral_system_prompt: str = None,
log_prefix_chars: int = 100,
log_prefix: str = "",
@ -280,6 +281,7 @@ def init_agent(
agent.save_trajectories = save_trajectories
agent.verbose_logging = verbose_logging
agent.quiet_mode = quiet_mode
agent.tool_progress_mode = tool_progress_mode
agent.ephemeral_system_prompt = ephemeral_system_prompt
agent.platform = platform # "cli", "telegram", "discord", "whatsapp", etc.
agent._user_id = user_id # Platform user identifier (gateway sessions)

View file

@ -1986,6 +1986,58 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"(possible upstream error or malformed SSE response)."
)
# A stream that delivered a tool call but only partial/unparseable
# JSON args splits into two very different cases:
#
# 1. Provider sent finish_reason="length" → a genuine output-cap
# truncation. Boosting max_tokens on retry is the right move.
#
# 2. Provider sent NO finish_reason (the SSE simply stopped after
# the opening "{" with no terminator and no [DONE]) → the
# upstream dropped/stalled the connection mid tool-call. This
# is NOT an output cap — the model never reported hitting one.
# Some dedicated endpoints (e.g. NVIDIA Nemotron Ultra on the
# Nous dedicated endpoint) stall for minutes during large
# tool-arg generation, then close the stream cleanly without a
# finish_reason. Stamping "length" here sends it down the
# max_tokens-boost truncation path, which retries 3× to no
# effect and finally reports the misleading "Response truncated
# due to output length limit" — the red herring this guards
# against. Route it through the partial-stream-stub path
# instead so the loop reports an honest mid-tool-call stream
# drop and fails fast rather than escalating output budget.
_tool_args_dropped_no_finish = has_truncated_tool_args and finish_reason is None
if _tool_args_dropped_no_finish:
_dropped_names = [
(tool_calls_acc[idx]["function"]["name"] or "?")
for idx in sorted(tool_calls_acc)
]
logger.warning(
"Stream ended with no finish_reason while a tool call's "
"arguments were still incomplete (tools=%s); treating as a "
"mid-tool-call stream drop, not an output-length truncation.",
_dropped_names,
)
full_reasoning = "".join(reasoning_parts) or None
mock_message = SimpleNamespace(
role=role,
content=full_content,
tool_calls=None,
reasoning_content=full_reasoning,
)
mock_choice = SimpleNamespace(
index=0,
message=mock_message,
finish_reason=FINISH_REASON_LENGTH,
)
return SimpleNamespace(
id=PARTIAL_STREAM_STUB_ID,
model=model_name,
choices=[mock_choice],
usage=usage_obj,
_dropped_tool_names=_dropped_names or None,
)
effective_finish_reason = finish_reason or "stop"
if has_truncated_tool_args:
effective_finish_reason = "length"

View file

@ -91,6 +91,7 @@ AUTH_TYPE_OAUTH = "oauth"
AUTH_TYPE_API_KEY = "api_key"
SOURCE_MANUAL = "manual"
SOURCE_MANUAL_DEVICE_CODE = f"{SOURCE_MANUAL}:device_code"
STRATEGY_FILL_FIRST = "fill_first"
STRATEGY_ROUND_ROBIN = "round_robin"

View file

@ -702,7 +702,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
if agent._should_emit_quiet_tool_messages():
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
agent._safe_print(f" {cute_msg}")
elif not agent.quiet_mode:
elif getattr(agent, "tool_progress_mode", "all") != "off":
_preview_str = _multimodal_text_summary(function_result)
if agent.verbose_logging:
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")

View file

@ -48,6 +48,7 @@ import {
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
mergeSessionPage,
sessionPinId,
setAwaitingResponse,
@ -130,12 +131,18 @@ function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
}
// Rows a session refresh must preserve even if the aggregator omits them:
// in-flight first turns (message_count 0), pinned rows aged off the page, and
// the actively-viewed chat (its "working" flag clears a beat before the
// aggregator sees the persisted row). Pass `scope` to only keep the active row
// when it belongs to the profile being paged.
// in-flight first turns (message_count 0), pinned rows aged off the page, the
// actively-viewed chat (its "working" flag clears a beat before the aggregator
// sees the persisted row), and sessions whose turn just settled (same race, but
// for a chat the user has already navigated away from). Pass `scope` to only
// keep the active row when it belongs to the profile being paged.
function sessionsToKeep(scope?: string): Set<string> {
const keep = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
const keep = new Set<string>([
...$workingSessionIds.get(),
...$pinnedSessionIds.get(),
...getRecentlySettledSessionIds()
])
const active = $selectedStoredSessionId.get()
if (active) {

View file

@ -6,6 +6,7 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
@ -57,8 +58,17 @@ export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
const a = t.settings.appearance
// Themes save per profile. Surface that only when the user actually has more
// than one profile (single-profile installs never see the distinction).
const showProfileNote = profiles.length > 1
const activeProfileName =
profiles.find(profile => normalizeProfileKey(profile.name) === activeProfileKey)?.name ?? activeProfileKey
const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label }))
const toolOptions = [
@ -98,43 +108,50 @@ export function AppearanceSettings() {
<ListRow
below={
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
<>
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
</div>
</button>
)
})}
</div>
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
})}
</div>
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.themeProfileNote(activeProfileName)}
</p>
)}
</>
}
description={a.themeDesc}
title={a.themeTitle}

View file

@ -292,7 +292,8 @@ export const en: Translations = {
technical: 'Technical',
technicalDesc: 'Include raw tool args/results and low-level details.',
themeTitle: 'Theme',
themeDesc: 'Desktop palettes only. The selected mode is applied on top.'
themeDesc: 'Desktop palettes only. The selected mode is applied on top.',
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,

View file

@ -215,7 +215,8 @@ export const ja = defineLocale({
technical: 'テクニカル',
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
themeTitle: 'テーマ',
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。'
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
themeProfileNote: profile => `${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`
},
fieldLabels: defineFieldCopy({
model: 'デフォルトモデル',

View file

@ -219,6 +219,7 @@ export interface Translations {
technicalDesc: string
themeTitle: string
themeDesc: string
themeProfileNote: (profile: string) => string
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>

View file

@ -209,7 +209,8 @@ export const zhHant = defineLocale({
technical: '技術',
technicalDesc: '包含原始工具參數、結果與底層細節。',
themeTitle: '主題',
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。'
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`
},
fieldLabels: defineFieldCopy({
model: '預設模型',

View file

@ -287,7 +287,8 @@ export const zh: Translations = {
technical: '技术',
technicalDesc: '包含原始工具参数/结果及底层细节。',
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。'
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`
},
fieldLabels: defineFieldCopy({
model: '默认模型',

View file

@ -3,11 +3,19 @@ import { describe, expect, it } from 'vitest'
import { gatewayEventRequiresSessionId } from './gateway-events'
describe('gateway event routing', () => {
it('requires explicit session ids for async session-scoped events', () => {
expect(gatewayEventRequiresSessionId('message.delta')).toBe(true)
expect(gatewayEventRequiresSessionId('tool.start')).toBe(true)
it('drops only unscoped subagent events (genuinely background work)', () => {
expect(gatewayEventRequiresSessionId('subagent.progress')).toBe(true)
expect(gatewayEventRequiresSessionId('approval.request')).toBe(true)
expect(gatewayEventRequiresSessionId('subagent.start')).toBe(true)
})
it('attributes unscoped foreground turn events to the active chat', () => {
// These must NOT be dropped when unscoped — they are the focused turn's own
// output, and dropping them loses the live response until a refetch (#42178).
expect(gatewayEventRequiresSessionId('message.delta')).toBe(false)
expect(gatewayEventRequiresSessionId('message.complete')).toBe(false)
expect(gatewayEventRequiresSessionId('reasoning.delta')).toBe(false)
expect(gatewayEventRequiresSessionId('tool.start')).toBe(false)
expect(gatewayEventRequiresSessionId('approval.request')).toBe(false)
})
it('allows global events to remain unscoped', () => {

View file

@ -7,37 +7,24 @@ interface RpcEventLike {
type?: string
}
const SESSION_SCOPED_EVENT_TYPES = new Set([
'approval.request',
'clarify.request',
'error',
'message.complete',
'message.delta',
'message.start',
'reasoning.available',
'reasoning.delta',
'secret.request',
'status.update',
'subagent.complete',
'subagent.progress',
'subagent.spawn_requested',
'subagent.start',
'subagent.thinking',
'subagent.tool',
'sudo.request',
'thinking.delta'
])
function asRecord(payload: unknown): Record<string, unknown> {
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
}
/**
* Whether an unscoped event (no `session_id`) must be dropped rather than
* attributed to the focused chat.
*
* Only `subagent.*` qualifies: it describes background/async work that must
* never attach to whichever chat happens to be focused. Every other scoped
* event message/reasoning/thinking/tool/status/prompt is, when unscoped,
* the active turn's own output. The gateway always stamps a *background*
* session's events with that session's id, so a missing id can only mean "the
* focused turn". #42178 dropped those too, which silently swallowed the live
* answer; it then reappeared only after a transcript refetch (manual refresh).
*/
export function gatewayEventRequiresSessionId(eventType: string | undefined): boolean {
if (!eventType) {
return false
}
return SESSION_SCOPED_EVENT_TYPES.has(eventType) || eventType.startsWith('tool.')
return eventType?.startsWith('subagent.') ?? false
}
export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {

View file

@ -1,8 +1,16 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { $attentionSessionIds, mergeSessionPage, sessionPinId, setSessionAttention } from './session'
import {
$attentionSessionIds,
$workingSessionIds,
getRecentlySettledSessionIds,
mergeSessionPage,
sessionPinId,
setSessionAttention,
setSessionWorking
} from './session'
const session = (over: Partial<SessionInfo>): SessionInfo => ({
archived: false,
@ -129,3 +137,61 @@ describe('mergeSessionPage', () => {
expect(merged.map(s => s.id)).toEqual(['tip', 'other'])
})
})
describe('getRecentlySettledSessionIds', () => {
afterEach(() => {
vi.useRealTimers()
$workingSessionIds.set([])
// Drain anything left in the grace map so tests stay isolated.
for (const id of getRecentlySettledSessionIds(Number.MAX_SAFE_INTEGER)) {
void id
}
})
it('keeps a session for the grace window after its turn settles, then drops it', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
$workingSessionIds.set([])
// A turn starts then ends: the working→idle transition grants grace.
setSessionWorking('s1', true)
setSessionWorking('s1', false)
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
// Still inside the window.
vi.setSystemTime(29_000)
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
// Past the window: the entry is pruned on read.
vi.setSystemTime(31_000)
expect(getRecentlySettledSessionIds()).toEqual([])
})
it('does not grant grace when the session was never working (idle re-asserts)', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
$workingSessionIds.set([])
// updateSessionState re-asserts `false` for idle sessions on every tick;
// these must not pin an idle chat into the keep-set indefinitely.
setSessionWorking('idle', false)
setSessionWorking('idle', false)
expect(getRecentlySettledSessionIds()).toEqual([])
})
it('clears the grace timer when the session goes busy again', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
$workingSessionIds.set([])
setSessionWorking('s2', true)
setSessionWorking('s2', false)
expect(getRecentlySettledSessionIds()).toEqual(['s2'])
// A new turn for the same session is "working" again — drop it from the
// settled set so it's tracked as working, not recently-finished.
setSessionWorking('s2', true)
expect(getRecentlySettledSessionIds()).toEqual([])
})
})

View file

@ -202,6 +202,47 @@ function clearSessionWatchdog(sessionId: string) {
}
}
// A session's "working" flag clears the instant its turn ends, but the
// cross-profile aggregator (listSessions with min_messages=1) only sees the
// just-persisted first turn a beat later. The active chat is shielded from that
// race by sessionsToKeep(), but a brand-new session that finished *while you
// were viewing a different chat* is, at the next refresh, neither working,
// pinned, nor active — so mergeSessionPage() evicts it. Nothing re-fetches
// afterward, so it stays gone until the app restarts. (Repro: start a new chat,
// then click another session before the first reply lands.)
//
// To bridge that window we keep a session in the merge keep-set for a short
// grace period after its turn settles, giving the aggregator time to catch up.
// Entries auto-expire, so this never accumulates and can't resurrect a deleted
// session (mergeSessionPage only revives rows still present in the in-memory
// list, which optimistic delete/archive already drops).
const SESSION_SETTLE_GRACE_MS = 30 * 1000
const settledSessionExpiry = new Map<string, number>()
function markSessionSettled(sessionId: string) {
settledSessionExpiry.set(sessionId, Date.now() + SESSION_SETTLE_GRACE_MS)
}
function clearSessionSettled(sessionId: string) {
settledSessionExpiry.delete(sessionId)
}
/** Stored ids of sessions whose turn ended within the grace window. Prunes
* expired entries as it reads, so it stays bounded without a timer. */
export function getRecentlySettledSessionIds(now: number = Date.now()): string[] {
const live: string[] = []
for (const [id, expiry] of settledSessionExpiry) {
if (expiry > now) {
live.push(id)
} else {
settledSessionExpiry.delete(id)
}
}
return live
}
/** Call when a streaming event for a session lands. Refreshes the watchdog
* so the session keeps its "working" status as long as data keeps coming. */
export function noteSessionActivity(sessionId: string | null | undefined) {
@ -243,13 +284,24 @@ export function setSessionWorking(sessionId: string | null | undefined, working:
return
}
const wasWorking = $workingSessionIds.get().includes(sessionId)
toggleMembership(setWorkingSessionIds, sessionId, working)
// Bookend the watchdog: arm on enter, disarm on leave. A later
// noteSessionActivity() from a streaming event refreshes the timer.
if (working) {
clearSessionSettled(sessionId)
armSessionWatchdog(sessionId)
} else {
clearSessionWatchdog(sessionId)
// Only grant grace on a real working→idle transition (updateSessionState
// re-asserts `false` on every state tick, which must not keep extending the
// window). This keeps the just-finished session visible long enough for the
// aggregator to return its now-persisted row.
if (wasWorking) {
markSessionSettled(sessionId)
}
}
}

View file

@ -9,15 +9,28 @@
* The two are persisted independently. Shift+X toggles light/dark.
*/
import { useStore } from '@nanostores/react'
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
// Legacy global skin (pre per-profile themes). Still the inheritance fallback
// for any profile without its own assignment, so single-profile users and old
// installs are unaffected.
const SKIN_KEY = 'hermes-desktop-theme-v2'
const MODE_KEY = 'hermes-desktop-mode-v1'
// Per-profile skin + light/dark mode assignments: { [profileKey]: value }. A
// profile inherits the global default until it's given its own appearance.
const PROFILE_SKINS_KEY = 'hermes-desktop-profile-themes-v1'
const PROFILE_MODES_KEY = 'hermes-desktop-profile-modes-v1'
// Last active profile, recorded so the boot-time paint can pick that profile's
// theme before the gateway reports which profile actually launched.
const LAST_PROFILE_KEY = 'hermes-desktop-active-profile-v1'
const RETIRED_SKINS = new Set(['nous-light', 'default', 'gold'])
export type ThemeMode = 'light' | 'dark' | 'system'
@ -27,9 +40,36 @@ const INJECTED_FONT_URLS = new Set<string>()
const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' =>
mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
const normalizeSkin = (name: string | null | undefined): string =>
const normalizeSkin = (name: string | null): string =>
name && BUILTIN_THEMES[name] && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME
const normalizeMode = (value: string | null): ThemeMode =>
value === 'light' || value === 'dark' || value === 'system' ? value : 'light'
// ─── Per-profile appearance persistence ─────────────────────────────────────
// Skin and mode are each stored per profile. "default" isn't a real profile —
// it *is* the legacy global slot, so it reads/writes the global directly. Named
// profiles get their own entry and fall back to that global until assigned, so
// unassigned profiles and pre-per-profile installs stay on the global value.
const profilePref = <T extends string>(record: string, legacy: string, normalize: (v: string | null) => T) => ({
resolve: (profile: string): T => normalize(storedStringRecord(record)[profile] ?? storedString(legacy)),
assign: (profile: string, value: T): void => {
if (profile === 'default') {
persistString(legacy, value)
} else {
persistStringRecord(record, { ...storedStringRecord(record), [profile]: value })
}
}
})
export const skinPref = profilePref(PROFILE_SKINS_KEY, SKIN_KEY, normalizeSkin)
export const modePref = profilePref(PROFILE_MODES_KEY, MODE_KEY, normalizeMode)
// Last active profile — lets the boot paint pick its appearance before the
// gateway reports which profile actually launched.
const readBootProfileKey = () => normalizeProfileKey(storedString(LAST_PROFILE_KEY))
const rememberActiveProfileKey = (profile: string) => persistString(LAST_PROFILE_KEY, profile)
// ─── Color math (for synthesised light variants of dark-only skins) ────────
function hexToRgb(hex: string): [number, number, number] | null {
@ -231,12 +271,13 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
}
}
// Boot-time paint to avoid a flash before <ThemeProvider> mounts.
// Boot-time paint to avoid a flash before <ThemeProvider> mounts. Use the last
// active profile's appearance so a non-default profile relaunch paints its own
// skin + light/dark mode.
if (typeof window !== 'undefined') {
const skin = normalizeSkin(window.localStorage.getItem(SKIN_KEY))
const mode = (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light'
const resolved = resolveMode(mode)
applyTheme(deriveTheme(skin, resolved), resolved)
const profile = readBootProfileKey()
const resolved = resolveMode(modePref.resolve(profile))
applyTheme(deriveTheme(skinPref.resolve(profile), resolved), resolved)
}
// ─── Context ────────────────────────────────────────────────────────────────
@ -264,29 +305,46 @@ const ThemeContext = createContext<ThemeContextValue>({
})
export function ThemeProvider({ children }: { children: ReactNode }) {
// Skin + mode are assigned per profile; the active profile drives which
// appearance shows. Single-profile users only ever see "default", so their
// behavior is unchanged.
const profileKey = normalizeProfileKey(useStore($activeGatewayProfile))
const [themeName, setThemeNameState] = useState(() =>
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : normalizeSkin(window.localStorage.getItem(SKIN_KEY))
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : skinPref.resolve(readBootProfileKey())
)
const [mode, setModeState] = useState<ThemeMode>(() =>
typeof window === 'undefined' ? 'light' : ((window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light')
typeof window === 'undefined' ? 'light' : modePref.resolve(readBootProfileKey())
)
// Follow profile switches: paint the profile's assigned skin + mode and
// remember it for the next boot's first paint.
useEffect(() => {
rememberActiveProfileKey(profileKey)
setThemeNameState(skinPref.resolve(profileKey))
setModeState(modePref.resolve(profileKey))
}, [profileKey])
const systemDark = useMediaQuery('(prefers-color-scheme: dark)')
const resolvedMode = resolveMode(mode, systemDark)
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
// Assign to whichever profile is live right now (read fresh so the callbacks
// stay stable across profile switches).
const liveProfile = () => normalizeProfileKey($activeGatewayProfile.get())
const setTheme = useCallback((name: string) => {
const next = normalizeSkin(name)
setThemeNameState(next)
window.localStorage.setItem(SKIN_KEY, next)
skinPref.assign(liveProfile(), next)
}, [])
const setMode = useCallback((next: ThemeMode) => {
setModeState(next)
window.localStorage.setItem(MODE_KEY, next)
modePref.assign(liveProfile(), next)
}, [])
// The light/dark toggle (Shift+X by default) is owned by the keybind runtime

View file

@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { modePref, skinPref } from './context'
import { DEFAULT_SKIN_NAME } from './presets'
// Skin and mode share one per-profile contract, so assert it once over both.
interface Pref {
resolve: (profile: string) => string
assign: (profile: string, value: string) => void
}
const cases = [
{ name: 'skin', pref: skinPref as unknown as Pref, fallback: DEFAULT_SKIN_NAME, a: 'ember', b: 'midnight', junk: 'nope' },
{ name: 'mode', pref: modePref as unknown as Pref, fallback: 'light', a: 'dark', b: 'system', junk: 'dusk' }
]
describe.each(cases)('per-profile $name', ({ pref, fallback, a, b, junk }) => {
beforeEach(() => window.localStorage.clear())
it('falls back to the default when unassigned', () => {
expect(pref.resolve('default')).toBe(fallback)
expect(pref.resolve('work')).toBe(fallback)
})
it('keeps each profile on its own value', () => {
pref.assign('work', a)
pref.assign('default', b)
expect(pref.resolve('work')).toBe(a)
expect(pref.resolve('default')).toBe(b)
})
it('lets unassigned profiles inherit the default profile as the global fallback', () => {
pref.assign('default', a)
expect(pref.resolve('never-themed')).toBe(a)
})
it('normalizes an unknown stored value back to the default', () => {
pref.assign('work', junk)
expect(pref.resolve('work')).toBe(fallback)
})
})

14
cli.py
View file

@ -2801,6 +2801,12 @@ def _collect_query_images(query: str | None, image_arg: str | None = None) -> tu
return message, deduped
# Strip OSC escape sequences (e.g. OSC-8 hyperlinks) that prompt_toolkit's
# ANSI parser can't handle — it strips \x1b but passes the payload through
# as literal text, garbling the TUI output.
_OSC_ESCAPE_RE = re.compile(r"\x1b\][\s\S]*?(?:\x07|\x1b\\)")
class ChatConsole:
"""Rich Console adapter for prompt_toolkit's patch_stdout context.
@ -2827,6 +2833,10 @@ class ChatConsole:
self._inner.width = shutil.get_terminal_size((80, 24)).columns
self._inner.print(*args, **kwargs)
output = self._buffer.getvalue()
# Strip OSC escape sequences (e.g. OSC-8 hyperlinks) before
# routing through prompt_toolkit's ANSI parser, which only
# handles CSI/SGR and passes OSC payload through as literal text.
output = _OSC_ESCAPE_RE.sub("", output)
for line in output.rstrip("\n").split("\n"):
_cprint(line)
@ -7659,6 +7669,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if self.agent:
self.agent.reasoning_callback = self._current_reasoning_callback()
# Keep the live agent's tool_progress_mode in sync so the
# tool_executor rendering path reflects the new mode this turn,
# without waiting for an agent rebuild.
self.agent.tool_progress_mode = self.tool_progress_mode
# Use raw ANSI codes via _cprint so the output is routed through
# prompt_toolkit's renderer. self.console.print() with Rich markup

View file

@ -8524,6 +8524,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
}
)
# The agent already persisted these messages to SQLite via
# _flush_messages_to_session_db(), so skip the DB write here
# to prevent the duplicate-write bug (#860 / #42039).
agent_persisted = self._session_db is not None
# Find only the NEW messages from this turn (skip history we loaded).
# Use the filtered history length (history_offset) that was actually
# passed to the agent, not len(history) which includes session_meta
@ -8541,6 +8546,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
self.session_store.append_to_transcript(
session_entry.session_id,
_user_entry,
skip_db=agent_persisted,
)
else:
history_len = agent_result.get("history_offset", len(history))
@ -8554,18 +8560,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
self.session_store.append_to_transcript(
session_entry.session_id,
_user_entry,
skip_db=agent_persisted,
)
if response:
self.session_store.append_to_transcript(
session_entry.session_id,
{"role": "assistant", "content": response, "timestamp": ts}
{"role": "assistant", "content": response, "timestamp": ts},
skip_db=agent_persisted,
)
else:
# The agent already persisted these messages to SQLite via
# _flush_messages_to_session_db(), so skip the DB write here
# to prevent the duplicate-write bug (#860). We still write
# to JSONL for backward compatibility and as a backup.
agent_persisted = self._session_db is not None
# Attach the inbound platform message_id to the first user
# entry written this turn so platform-level quote-resolution
# (e.g. Yuanbao QuoteContextMiddleware's transcript fallback)

View file

@ -1182,6 +1182,24 @@ def _store_provider_state(
auth_store["active_provider"] = provider_id
def mark_provider_active_if_unset(provider_id: str) -> None:
"""Set ``active_provider`` to *provider_id* only when none is set yet.
Used by ``hermes auth add`` OAuth paths that create credential-pool
entries directly (no singleton ``providers.<id>`` block). Adding the
very first credential for a provider should make it the active provider
so the setup wizard's ``_model_section_has_credentials()`` check (which
consults ``get_active_provider()``) does not report "No inference
provider configured". Subsequent adds for an already-active setup leave
the user's chosen active provider untouched.
"""
with _auth_store_lock():
auth_store = _load_auth_store()
if not (auth_store.get("active_provider") or "").strip():
auth_store["active_provider"] = provider_id
_save_auth_store(auth_store)
def is_known_auth_provider(provider_id: str) -> bool:
normalized = (provider_id or "").strip().lower()
return normalized in PROVIDER_REGISTRY or normalized in SERVICE_PROVIDER_NAMES
@ -3355,6 +3373,7 @@ def _sync_codex_pool_entries(
auth_store: Dict[str, Any],
tokens: Dict[str, str],
last_refresh: Optional[str],
previous_singleton_tokens: Optional[Dict[str, str]] = None,
) -> None:
"""Mirror a fresh Codex re-auth into the credential_pool OAuth entries.
@ -3370,24 +3389,34 @@ def _sync_codex_pool_entries(
OAuth flow when the user logged in via ``hermes setup`` / the model
picker. Always synced with the fresh tokens.
* ``manual:device_code`` entries created by ``hermes auth add openai-codex``
that use the same device-code OAuth mechanism. An interactive re-auth
proves the user owns the ChatGPT account, so it is safe (and expected)
to refresh these entries too. Without this, a user who once ran the
``hermes auth add`` workaround for #33000 would silently leave that
manual entry stale on every subsequent re-auth, recreating the issue
reported in #33538.
that use the same device-code OAuth mechanism. ONLY synced if the
entry's existing access_token matches the *previous* singleton
access_token (i.e. the entry is a legacy singleton-alias from the
#33000 workaround era). Manual entries whose tokens never matched the
singleton represent INDEPENDENT accounts added via
``hermes auth add openai-codex`` and must not be overwritten by a
re-auth that targeted a different account (regression for #39236).
The original #33538 fix refreshed every ``manual:device_code`` entry
unconditionally. That worked when ``manual:device_code`` only meant
"legacy alias of the singleton", but the same source string is now
also produced by independent-account additions, and the broad sync
silently clobbered distinct accounts with the latest-authenticated
token pair. The access_token-match check distinguishes the two cases
without changing the source-string contract.
What does NOT get refreshed:
* ``manual:api_key`` and any other non-device-code manual sources those
are independent credentials (an explicit API key, a different ChatGPT
account, etc.) and must not be overwritten by a single re-auth.
* ``manual:device_code`` entries whose access_token does NOT match the
previous singleton see above; these are independent accounts.
Error markers (``last_status``, ``last_error_*``) are also cleared on
every device-code-backed entry even those whose tokens we did not
rewrite so that an interactive re-auth gives every relevant pool entry
a fresh selection chance instead of leaving them marked unhealthy from a
pre-re-auth 401.
Error markers (``last_status``, ``last_error_*``) are cleared ONLY on
entries that actually had their tokens rewritten by this re-auth.
Independent entries keep their own error state (their 401/429 markers
belong to that account's own auth flow, not this re-auth).
"""
access_token = tokens.get("access_token")
if not access_token:
@ -3399,15 +3428,34 @@ def _sync_codex_pool_entries(
entries = pool.get("openai-codex")
if not isinstance(entries, list):
return
# Sources whose tokens should be rewritten by a fresh Codex device-code
# OAuth re-auth. ``manual:api_key`` and unknown sources are intentionally
# excluded — they represent independent credentials.
REFRESHABLE_SOURCES = {"device_code", "manual:device_code"}
# Previous singleton access_token (before this re-auth overwrote it) —
# used to distinguish legacy singleton-aliases from independent accounts.
# When None or empty, no manual entry can be treated as an alias (which
# is the right default for first-ever-save or a freshly initialized
# auth.json).
prev_at = None
if isinstance(previous_singleton_tokens, dict):
prev_at = previous_singleton_tokens.get("access_token") or None
for entry in entries:
if not isinstance(entry, dict):
continue
source = entry.get("source")
if source not in REFRESHABLE_SOURCES:
if source == "device_code":
# Singleton-seeded mirror — always refresh.
refresh_this_entry = True
elif source == "manual:device_code":
# Refresh only if this entry's existing access_token matches the
# previous singleton access_token (i.e. it is a true alias of the
# singleton from the #33000 workaround era). An entry with its
# own distinct token material is an independent account and must
# be left alone (#39236).
refresh_this_entry = bool(
prev_at and entry.get("access_token") == prev_at
)
else:
# ``manual:api_key`` and any future non-device-code sources.
refresh_this_entry = False
if not refresh_this_entry:
continue
entry["access_token"] = access_token
if refresh_token:
@ -3429,13 +3477,24 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "openai-codex") or {}
# Capture the previous singleton tokens BEFORE overwriting them. The
# pool-sync step uses this to distinguish legacy singleton-aliases
# (which should be refreshed) from independent accounts that
# ``hermes auth add openai-codex`` created (which must not be
# overwritten — see #39236).
previous_singleton_tokens = state.get("tokens") if isinstance(state.get("tokens"), dict) else None
state["tokens"] = tokens
state["last_refresh"] = last_refresh
state["auth_mode"] = "chatgpt"
if label and str(label).strip():
state["label"] = str(label).strip()
_save_provider_state(auth_store, "openai-codex", state)
_sync_codex_pool_entries(auth_store, tokens, last_refresh)
_sync_codex_pool_entries(
auth_store,
tokens,
last_refresh,
previous_singleton_tokens=previous_singleton_tokens,
)
_save_auth_store(auth_store)

View file

@ -13,6 +13,7 @@ from agent.credential_pool import (
AUTH_TYPE_OAUTH,
CUSTOM_POOL_PREFIX,
SOURCE_MANUAL,
SOURCE_MANUAL_DEVICE_CODE,
STATUS_EXHAUSTED,
STRATEGY_FILL_FIRST,
STRATEGY_ROUND_ROBIN,
@ -312,15 +313,35 @@ def auth_add_command(args) -> None:
creds["tokens"]["access_token"],
_oauth_default_label(provider, len(pool.entries()) + 1),
)
auth_mod._save_codex_tokens(
creds["tokens"],
last_refresh=creds.get("last_refresh"),
# Add a distinct, self-contained pool entry per account (matching the
# xai-oauth / google-gemini-cli / qwen-oauth patterns) instead of
# routing through the singleton ``_save_codex_tokens`` save path.
# The singleton round-trip collapsed every added account into the
# latest login: a second ``hermes auth add openai-codex`` overwrote
# the first account's singleton-mirrored ``device_code`` entry rather
# than creating an independent one (#39236). ``manual:device_code``
# entries refresh from their own token pair, so they need no singleton
# shadow.
entry = PooledCredential(
provider=provider,
id=uuid.uuid4().hex[:6],
label=label,
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=SOURCE_MANUAL_DEVICE_CODE,
access_token=creds["tokens"]["access_token"],
refresh_token=creds["tokens"].get("refresh_token"),
base_url=creds.get("base_url"),
last_refresh=creds.get("last_refresh"),
)
pool = load_pool(provider)
entry = next((item for item in pool.entries() if item.source == "device_code"), None)
shown_label = entry.label if entry is not None else label
print(f'Saved {provider} OAuth device-code credentials: "{shown_label}"')
first_credential = not pool.entries()
pool.add_entry(entry)
# Adding the first Codex credential should make it the active provider
# (the old singleton save path did this implicitly via
# _save_provider_state). Subsequent adds leave the active provider as-is.
if first_credential:
auth_mod.mark_provider_active_if_unset(provider)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
return
if provider == "xai-oauth":

View file

@ -355,6 +355,7 @@ class CLIAgentSetupMixin:
disabled_toolsets=self.disabled_toolsets,
verbose_logging=self.verbose,
quiet_mode=not self.verbose,
tool_progress_mode=getattr(self, "tool_progress_mode", "all"),
ephemeral_system_prompt=self.system_prompt if self.system_prompt else None,
prefill_messages=self.prefill_messages or None,
reasoning_config=self.reasoning_config,

View file

@ -4959,6 +4959,79 @@ def _purge_electron_build_cache(desktop_dir: Path) -> list[Path]:
return removed
def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
"""Terminate any running desktop app executing from this build's ``release``
dir so a rebuild can replace its (otherwise locked) executable.
On Windows a running ``Hermes.exe`` keeps an exclusive lock on
``release/win-unpacked/Hermes.exe``. electron-builder's pack then can't
delete the stale binary and dies with ``remove \\Hermes.exe: Access is
denied`` / ``ERR_ELECTRON_BUILDER_CANNOT_EXECUTE`` (before-pack hits the same
EPERM cleaning the dir). The retry path repeats the failure because the lock
is still held. POSIX lets you unlink a running binary, so this is a no-op
off-Windows.
Scope is deliberately narrow: only processes whose executable lives *inside*
this desktop's ``release`` tree are stopped — a packaged install elsewhere or
an unrelated "Hermes" process is never touched. Best-effort: never raises.
Returns the PIDs we asked to stop.
"""
if sys.platform != "win32":
return []
try:
import psutil
except Exception:
return []
try:
release_dir = (desktop_dir / "release").resolve()
except OSError:
return []
if not release_dir.is_dir():
return []
me = os.getpid()
victims = []
try:
proc_iter = psutil.process_iter(["pid", "exe"])
except Exception:
return []
for proc in proc_iter:
try:
info = proc.info
except Exception:
continue
pid = info.get("pid")
exe = info.get("exe")
if not exe or pid is None or pid == me:
continue
try:
exe_path = Path(exe).resolve()
except (OSError, ValueError):
continue
if release_dir in exe_path.parents:
victims.append(proc)
stopped: list[int] = []
for proc in victims:
try:
proc.terminate()
stopped.append(int(proc.pid))
except Exception:
continue
if stopped:
# Wait for the handles (and thus the file locks) to actually release.
try:
_, alive = psutil.wait_procs(victims, timeout=5)
for proc in alive:
try:
proc.kill()
except Exception:
continue
except Exception:
pass
return stopped
def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None:
"""Make a locally-built (unsigned) macOS desktop app survive in-place self-update.
@ -5115,6 +5188,15 @@ def cmd_gui(args: argparse.Namespace):
build_label = "source build" if source_mode else "packaged app"
print(f"→ Building desktop {build_label}...")
build_script = "build" if source_mode else "pack"
if not source_mode:
# A running desktop instance launched from release/win-unpacked
# holds Hermes.exe locked on Windows, so the pack can't replace
# it ("Access is denied" / ERR_ELECTRON_BUILDER_CANNOT_EXECUTE).
# Stop it first so the rebuild — including the installer's
# headless --update rebuild — succeeds instead of failing cryptically.
stopped = _stop_desktop_processes_locking_build(desktop_dir)
if stopped:
print(f" ⚠ Stopped running desktop app to free the build output (pid {', '.join(map(str, stopped))})")
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if build_result.returncode != 0 and not source_mode:
# A corrupt cached Electron zip makes `pack` fail with an ENOENT
@ -5135,10 +5217,16 @@ def cmd_gui(args: argparse.Namespace):
print(" ⚠ Desktop build failed; cleared cached Electron download and retrying once...")
for p in purged:
print(f" - {p}")
# The purge can't remove a win-unpacked tree whose Hermes.exe
# is still locked by a running instance; stop it before retry.
_stop_desktop_processes_locking_build(desktop_dir)
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if build_result.returncode != 0:
print("✗ Desktop GUI build failed")
print(f" Run manually: cd apps/desktop && npm run {build_script}")
if sys.platform == "win32":
print(" If this says \"Access is denied\" on Hermes.exe, close any")
print(" running Hermes desktop window and retry.")
sys.exit(build_result.returncode or 1)
packaged_executable = _desktop_packaged_executable(desktop_dir)
if not source_mode:
@ -6136,12 +6224,14 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None:
_mark_skip_upstream_prompt()
return
# Fetch upstream
# Fetch upstream main only. This sync compares upstream/main with
# origin/main, so there's no reason to pull every upstream ref — and a bare
# fetch drags in thousands of auto-generated branches.
print()
print("→ Fetching upstream...")
try:
subprocess.run(
git_cmd + ["fetch", "upstream", "--quiet"],
git_cmd + ["fetch", "upstream", "main", "--quiet"],
cwd=cwd,
capture_output=True,
check=True,
@ -7376,14 +7466,16 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
if sys.platform == "win32":
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
# Fetch both origin and upstream; prefer upstream as the canonical reference.
# Fetch only the branch we compare against; prefer upstream as the canonical
# reference. A bare `git fetch <remote>` pulls every ref, and this repo has
# thousands of auto-generated branches, so scope the fetch to <branch>.
# Note: upstream/<branch> may not exist for non-main branches (a fork's
# bb/gui has no upstream counterpart), so when the caller picks a
# non-default branch we skip the upstream probe and use origin directly.
if branch == "main":
print("→ Fetching from upstream...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "upstream"],
git_cmd + ["fetch", "upstream", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
@ -7392,7 +7484,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
# Fallback to origin if upstream doesn't exist
print("→ Fetching from origin...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "origin"],
git_cmd + ["fetch", "origin", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
@ -7406,7 +7498,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
# Non-default branch: compare against origin/<branch> directly.
print("→ Fetching from origin...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "origin"],
git_cmd + ["fetch", "origin", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
@ -7914,9 +8006,16 @@ def _cmd_update_impl(args, gateway_mode: bool):
# Fetch and pull
try:
# Resolve the target branch up front so the fetch can be scoped to it.
# A bare `git fetch origin` pulls every ref, and this repo carries
# thousands of auto-generated branches — an unscoped fetch can stall for
# minutes on a non-single-branch checkout. Fetch only what we update
# against.
branch = _resolve_update_branch(args)
print("→ Fetching updates...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "origin"],
git_cmd + ["fetch", "origin", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
@ -7948,11 +8047,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
current_branch = result.stdout.strip()
# Determine the target branch. Default is "main" (the long-standing
# CLI behavior); --branch overrides for callers that want to update
# against a non-default channel.
branch = _resolve_update_branch(args)
# If user is on a different branch than the update target, switch
# to the target. When the target is "main" this is the historical
# "always update against main" behavior; for any other target it's

View file

@ -8288,20 +8288,32 @@ async def get_models_analytics(days: int = 30):
# though uvicorn binds to 127.0.0.1.
# ---------------------------------------------------------------------------
# PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native
# Windows the import raises; catch and leave PtyBridge=None so the rest of
# the dashboard (sessions, jobs, metrics, config editor) still loads and the
# /api/pty endpoint cleanly refuses with a WSL-suggested message.
try:
from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError
_PTY_BRIDGE_AVAILABLE = True
except ImportError as _pty_import_err: # pragma: no cover - Windows-only path
PtyBridge = None # type: ignore[assignment]
_PTY_BRIDGE_AVAILABLE = False
# PTY bridge: POSIX uses pty_bridge (fcntl/termios/ptyprocess); native Windows
# uses win_pty_bridge (pywinpty/ConPTY, already a declared dependency). Both
# expose the same public surface — spawn/read/write/resize/close/is_available —
# so the /api/pty WebSocket handler needs no platform guards.
if sys.platform.startswith("win"):
try:
from hermes_cli.win_pty_bridge import WinPtyBridge as PtyBridge, PtyUnavailableError
_PTY_BRIDGE_AVAILABLE = True
except ImportError: # pragma: no cover - pywinpty missing
PtyBridge = None # type: ignore[assignment]
_PTY_BRIDGE_AVAILABLE = False
class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
"""Stub on platforms where pty_bridge can't be imported."""
pass
class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
"""Stub when win_pty_bridge cannot be imported."""
pass
else:
try:
from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError
_PTY_BRIDGE_AVAILABLE = True
except ImportError: # pragma: no cover - dev env without ptyprocess
PtyBridge = None # type: ignore[assignment]
_PTY_BRIDGE_AVAILABLE = False
class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
"""Stub on platforms where pty_bridge can't be imported."""
pass
_RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]")
_PTY_READ_CHUNK_TIMEOUT = 0.2

View file

@ -0,0 +1,179 @@
"""Windows ConPTY bridge for the `hermes dashboard` chat tab.
Drop-in counterpart to ``hermes_cli.pty_bridge.PtyBridge`` for native
Windows. Mirrors the exact public surface the ``/api/pty`` WebSocket
handler in ``hermes_cli.web_server`` consumes: ``spawn``, ``read``,
``write``, ``resize``, ``close``, ``is_available``, plus the
``PtyUnavailableError`` type.
Backed by ``pywinpty`` (already a declared win32 dependency in
pyproject.toml) instead of ``ptyprocess``/``fcntl``/``termios``, none of
which exist on native Windows. The read/write/terminate calls here match
the working winpty usage already shipping in ``tools/process_registry.py``.
"""
from __future__ import annotations
import os
import sys
import time
from typing import Optional, Sequence
try:
from winpty import PtyProcess # type: ignore
_PTY_AVAILABLE = sys.platform.startswith("win")
except ImportError: # pragma: no cover - non-Windows or pywinpty missing
PtyProcess = None # type: ignore
_PTY_AVAILABLE = False
__all__ = ["WinPtyBridge", "PtyUnavailableError"]
# Same clamp ceiling as the POSIX bridge: a broken winsize probe must never
# reach the resize call. ConPTY tolerates large values better than ioctl,
# but we keep parity to avoid layout surprises.
_MIN_DIMENSION = 1
_MAX_COLS = 2000
_MAX_ROWS = 1000
def _clamp(value: int, maximum: int) -> int:
try:
n = int(value)
except (TypeError, ValueError, OverflowError):
return _MIN_DIMENSION
if n < _MIN_DIMENSION:
return _MIN_DIMENSION
if n > maximum:
return maximum
return n
class PtyUnavailableError(RuntimeError):
"""Raised when a PTY cannot be created on this platform."""
class WinPtyBridge:
"""pywinpty-backed bridge with the same interface as ``PtyBridge``.
``web_server`` calls :meth:`read` inside ``run_in_executor``, so a
blocking/polling read here never stalls the event loop. ConPTY exposes
no selectable fd, so we poll with a short sleep instead of ``select``.
"""
def __init__(self, proc: "PtyProcess") -> None: # type: ignore[name-defined]
self._proc = proc
self._closed = False
# -- lifecycle --------------------------------------------------------
@classmethod
def is_available(cls) -> bool:
return bool(_PTY_AVAILABLE)
@classmethod
def spawn(
cls,
argv: Sequence[str],
*,
cwd: Optional[str] = None,
env: Optional[dict] = None,
cols: int = 80,
rows: int = 24,
) -> "WinPtyBridge":
if not _PTY_AVAILABLE:
if PtyProcess is None:
raise PtyUnavailableError(
"pywinpty is not installed. Install with: pip install pywinpty"
)
raise PtyUnavailableError("ConPTY is unavailable on this platform.")
spawn_env = (os.environ.copy() if env is None else dict(env))
if not spawn_env.get("TERM"):
spawn_env["TERM"] = "xterm-256color"
# pywinpty mirrors ptyprocess: dimensions=(rows, cols).
# This call shape is the one already used in tools/process_registry.py.
proc = PtyProcess.spawn( # type: ignore[union-attr]
list(argv),
cwd=cwd,
env=spawn_env,
dimensions=(rows, cols),
)
return cls(proc)
@property
def pid(self) -> int:
return int(self._proc.pid)
def is_alive(self) -> bool:
if self._closed:
return False
try:
return bool(self._proc.isalive())
except Exception:
return False
# -- I/O --------------------------------------------------------------
def read(self, timeout: float = 0.2) -> Optional[bytes]:
"""Up to 64 KiB of child output.
Returns bytes, ``b""`` when nothing is available this tick, or
``None`` once the child has exited (EOF).
"""
if self._closed:
return None
try:
data = self._proc.read(65536) # pywinpty returns str
except EOFError:
return None
except Exception:
return None
if not data:
# No fd to select on; poll politely so the executor thread
# doesn't pin a core while the TUI is idle.
time.sleep(min(timeout, 0.02))
return b""
if isinstance(data, bytes):
return data
# NOTE: pywinpty decodes internally, so a multibyte UTF-8 sequence
# can in theory split across reads. xterm.js tolerates the rare
# replacement char; this is the one fidelity tradeoff vs the POSIX
# raw-fd path.
return data.encode("utf-8", errors="replace")
def write(self, data: bytes) -> None:
if self._closed or not data:
return
try:
# The dashboard sends raw keystroke bytes; pywinpty.write wants text.
self._proc.write(data.decode("utf-8", errors="replace"))
except Exception:
return
def resize(self, cols: int, rows: int) -> None:
if self._closed:
return
cols = _clamp(cols, _MAX_COLS)
rows = _clamp(rows, _MAX_ROWS)
try:
self._proc.setwinsize(rows, cols) # pywinpty: (rows, cols)
except Exception:
pass
# -- teardown ---------------------------------------------------------
def close(self) -> None:
if self._closed:
return
self._closed = True
try:
self._proc.terminate(force=True)
except Exception:
pass
def __enter__(self) -> "WinPtyBridge":
return self
def __exit__(self, *_exc) -> None:
self.close()

View file

@ -163,7 +163,11 @@ agent_version = "local"
When `HERMES_NEMO_RELAY_PLUGINS_TOML` is set and initializes successfully, NeMo
Relay owns exporter lifecycle through that config. The direct
`HERMES_NEMO_RELAY_ATOF_*` fallback setup is skipped.
`HERMES_NEMO_RELAY_ATOF_*` fallback setup is skipped. If the same
`plugins.toml` observability config enables `atif`, the direct
`HERMES_NEMO_RELAY_ATIF_*` fallback setup is also skipped so Hermes does not
double-export trajectories on teardown. If `plugins.toml` initialization fails,
Hermes keeps the direct env-var fallbacks active for that run.
To enable NeMo Relay managed execution intercepts for provider and tool calls,
include an adaptive component in the same `plugins.toml`:

View file

@ -65,9 +65,11 @@ class _Runtime:
self.sessions: dict[str, _SessionState] = {}
self.subagent_parents: dict[str, _SubagentParent] = {}
self.atof_exporter: Any = None
self._atof_subscriber_name = "hermes.nemo_relay.atof"
self._plugin_config_initialized = self._configure_plugins_toml()
self._plugin_config_needs_reinit = False
if not self._plugin_config_initialized:
self._configure_atof()
self._activate_direct_fallbacks()
def _configure_plugins_toml(self) -> bool:
if not self.settings.plugins_config:
@ -78,17 +80,45 @@ class _Runtime:
return False
try:
self._ensure_plugin_config_output_dirs(self.settings.plugins_config)
result = initialize(self.settings.plugins_config)
if inspect.isawaitable(result):
asyncio.run(result)
_resolve_awaitable(initialize(self.settings.plugins_config))
return True
except RuntimeError:
logger.debug("NeMo Relay plugins.toml init skipped inside a running event loop")
return False
except Exception as exc:
logger.debug("NeMo Relay plugins.toml init failed: %s", exc, exc_info=True)
return False
def _clear_plugins_toml(self) -> None:
if not self._plugin_config_initialized:
return
plugin_mod = getattr(self.nemo_relay, "plugin", None)
clear = getattr(plugin_mod, "clear", None)
if not callable(clear):
return
try:
_resolve_awaitable(clear())
finally:
self._plugin_config_initialized = False
self._plugin_config_needs_reinit = bool(self.settings.plugins_config)
def _activate_direct_fallbacks(self) -> None:
self._plugin_config_needs_reinit = False
self._configure_atof()
def _maybe_reinitialize_plugins_toml(self) -> None:
if not self._plugin_config_needs_reinit or self._plugin_config_initialized:
return
self._plugin_config_initialized = self._configure_plugins_toml()
if not self._plugin_config_initialized:
self._activate_direct_fallbacks()
return
self._clear_atof()
self._plugin_config_needs_reinit = False
def _plugins_toml_owns_exporter(self, exporter_name: str) -> bool:
return self._plugin_config_initialized and _observability_exporter_enabled(
self.settings.plugins_config,
exporter_name,
)
def _ensure_plugin_config_output_dirs(self, config: dict[str, Any]) -> None:
for component in config.get("components", []):
if not isinstance(component, dict):
@ -109,7 +139,7 @@ class _Runtime:
Path(output_directory).mkdir(parents=True, exist_ok=True)
def _configure_atof(self) -> None:
if not self.settings.atof_enabled:
if not self.settings.atof_enabled or self.atof_exporter is not None:
return
config = self.nemo_relay.AtofExporterConfig()
if self.settings.atof_output_directory:
@ -121,16 +151,28 @@ class _Runtime:
else:
config.mode = self.nemo_relay.AtofExporterMode.Append
self.atof_exporter = self.nemo_relay.AtofExporter(config)
self.atof_exporter.register("hermes.nemo_relay.atof")
self.atof_exporter.register(self._atof_subscriber_name)
def _clear_atof(self) -> None:
if self.atof_exporter is None:
return
deregister = getattr(self.atof_exporter, "deregister", None)
if callable(deregister):
try:
deregister(self._atof_subscriber_name)
except Exception:
logger.debug("NeMo Relay ATOF deregister failed", exc_info=True)
self.atof_exporter = None
def ensure_session(self, kwargs: dict[str, Any]) -> _SessionState:
self._maybe_reinitialize_plugins_toml()
session_id = _session_id(kwargs)
state = self.sessions.get(session_id)
if state is not None:
return state
state = _SessionState(session_id=session_id)
if self.settings.atif_enabled:
if self.settings.atif_enabled and not self._plugins_toml_owns_exporter("atif"):
state.atif_exporter = self.nemo_relay.AtifExporter(
session_id,
self.settings.atif_agent_name,
@ -189,6 +231,13 @@ class _Runtime:
state.atif_exporter.deregister(state.atif_subscriber_name)
except Exception:
logger.debug("NeMo Relay ATIF deregister failed", exc_info=True)
if self._plugin_config_initialized and not self.sessions:
try:
self._clear_plugins_toml()
except Exception:
logger.debug("NeMo Relay plugins.toml clear failed", exc_info=True)
elif self.settings.plugins_config and not self.sessions:
self._plugin_config_needs_reinit = True
def mark(self, name: str, kwargs: dict[str, Any]) -> None:
state = self.ensure_session(kwargs)
@ -623,6 +672,19 @@ def _adaptive_mode(config: dict[str, Any] | None) -> str:
return "observe_only"
def _observability_exporter_enabled(
plugins_config: dict[str, Any] | None,
exporter_name: str,
) -> bool:
observability_config = _enabled_component_config(plugins_config, "observability")
if not isinstance(observability_config, dict):
return False
exporter_config = observability_config.get(exporter_name)
if not isinstance(exporter_config, dict):
return False
return exporter_config.get("enabled", True) is not False
def _env(name: str) -> str:
return os.environ.get(name, "").strip()

View file

@ -0,0 +1,121 @@
# Photon iMessage platform plugin
This plugin connects Hermes Agent to iMessage (and WhatsApp Business +
future Spectrum interfaces) through [Photon][photon] — a managed
service that handles the iMessage line allocation, delivery, and
abuse-prevention layer so users don't have to run their own Mac
relay.
The free tier uses Photon's shared iMessage line pool (`type: shared`)
and is the path we recommend for everyone who doesn't already pay for a
dedicated number.
## Architecture
```
┌─────────────────────────┐ HMAC-signed POSTs ┌──────────────────┐
│ Photon Spectrum cloud │ ──────────────────────► │ Hermes Agent │
│ (iMessage line owner) │ │ (Python) │
└─────────────────────────┘ JSON over loopback │ │
▲ ◄────────────────────── │ PhotonAdapter │
│ │ + aiohttp recv │
│ spectrum-ts │ │
│ SDK (Node) │ spawns + super- │
▼ │ vises ▼ │
┌─────────────────────────┐ ├──────────────────┤
│ Node sidecar │ ◄──── X-Hermes- ─ │ Node sidecar │
│ (plugins/.../sidecar) │ Sidecar-Token │ child process │
└─────────────────────────┘ └──────────────────┘
```
Inbound traffic is webhook-only — Hermes runs an aiohttp listener
that verifies `X-Spectrum-Signature` and dedupes on `message.id`.
Outbound traffic goes through a tiny Node sidecar that runs the
`spectrum-ts` SDK. Photon does not currently expose an HTTP
send-message endpoint; their own docs say:
> Pass `space.id` to `Space.send(...)` from a separate `spectrum-ts`
> SDK instance to reply. **No public HTTP send endpoint exists today.**
> — https://photon.codes/docs/webhooks/events
When Photon ships an HTTP send endpoint, `_sidecar_send` is the one
function that swaps and the sidecar disappears. The rest of the
plugin stays the same.
## First-time setup
```bash
# 1. One-shot setup: device login (opens browser) + project + user + sidecar deps
hermes photon setup --phone +15551234567
# 2. Expose your webhook URL to the public internet
# (cloudflared, ngrok, your gateway's public hostname, etc.)
# Then register it with Photon:
hermes photon webhook register https://your-host.example.com/photon/webhook
# 3. Save the signing secret it prints to ~/.hermes/.env
# as PHOTON_WEBHOOK_SECRET=...
# Photon only returns it ONCE.
# 4. Start the gateway
hermes gateway start --platform photon
```
`hermes photon setup` runs the RFC 8628 device-code login as its first
step — it opens `https://app.photon.codes/` for approval, then
provisions the Spectrum project + iMessage line. There is no separate
`login` command; like every other Hermes channel, onboarding goes
through one setup surface. Re-running `setup` reuses an existing token
and project, so it's safe to run again to finish a partial setup.
## Credentials
Stored in `~/.hermes/auth.json` under `credential_pool`:
```jsonc
{
"credential_pool": {
"photon": [
{ "access_token": "<dashboard-bearer>", "issued_at": ... }
],
"photon_project": [
{ "project_id": "...", "project_secret": "...", "name": "Hermes Agent" }
]
}
}
```
The per-URL webhook signing secret is treated like an API key and
lives in `~/.hermes/.env` as `PHOTON_WEBHOOK_SECRET`.
## Configuration knobs
All env vars are documented in `plugin.yaml`. The most important are:
| Env var | Default | Meaning |
|--------------------------|--------------------|-----------------------------------------|
| `PHOTON_PROJECT_ID` | from auth.json | Spectrum project ID |
| `PHOTON_PROJECT_SECRET` | from auth.json | Spectrum project secret (HTTP Basic) |
| `PHOTON_WEBHOOK_SECRET` | (unset) | Signing secret returned at register |
| `PHOTON_WEBHOOK_PORT` | 8788 | Local port for the aiohttp listener |
| `PHOTON_WEBHOOK_PATH` | /photon/webhook | Path under which the listener mounts |
| `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for sidecar control |
| `PHOTON_HOME_CHANNEL` | (unset) | Default space ID for cron delivery |
| `PHOTON_ALLOWED_USERS` | (unset) | Comma-separated E.164 allowlist |
## Limitations (current Photon API)
- **Attachments are metadata only.** Inbound webhooks include the
filename + MIME type but no download URL. The plugin surfaces a
text marker (`[Photon attachment received: …]`) so the agent knows
something arrived, but cannot read the bytes. Photon's docs note
an attachment retrieval endpoint is on the roadmap.
- **Outbound attachments are not supported yet.** Adding them is
straightforward once the sidecar wires up `attachment(...)` /
`space.send(attachment(...))` from `spectrum-ts`.
- **Reactions, message effects, polls** — not exposed yet; the
`spectrum-ts` SDK supports them, and the sidecar is the natural
place to add them when the agent has reason to use them.
[photon]: https://photon.codes/

View file

@ -0,0 +1,4 @@
"""Photon Spectrum (iMessage) platform plugin entry point."""
from .adapter import register
__all__ = ["register"]

View file

@ -0,0 +1,844 @@
"""
Photon Spectrum (iMessage) platform adapter for Hermes Agent.
Inbound:
Photon delivers signed JSON ``POST``s to a URL we register. The
adapter spins up an aiohttp server on ``PHOTON_WEBHOOK_PORT``,
verifies ``X-Spectrum-Signature`` (HMAC-SHA256 of
``v0:{timestamp}:{body}`` keyed by the per-URL signing secret),
rejects deliveries with a timestamp drift > 5 minutes, dedupes on
``message.id``, and dispatches a normalized ``MessageEvent`` to the
gateway runner via ``BasePlatformAdapter.handle_message``.
Outbound:
Photon does not currently expose a public HTTP send-message
endpoint, so the adapter spawns a small Node sidecar (see
``sidecar/index.mjs``) that runs the ``spectrum-ts`` SDK. Each
``send`` / ``send_typing`` call from Hermes is a loopback POST to
the sidecar with a shared bearer token.
When Photon ships an HTTP send endpoint we can collapse the sidecar
into ``_send_via_http`` and drop the Node dependency entirely.
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import logging
import os
import re
import secrets
import shutil
import signal
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError: # pragma: no cover - httpx is already a Hermes dep
HTTPX_AVAILABLE = False
httpx = None # type: ignore[assignment]
try:
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
web = None # type: ignore[assignment]
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)
from .auth import (
DEFAULT_SPECTRUM_HOST,
load_project_credentials,
_spectrum_host,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
_DEFAULT_WEBHOOK_PORT = 8788
_DEFAULT_WEBHOOK_PATH = "/photon/webhook"
_DEFAULT_WEBHOOK_BIND = "0.0.0.0"
_DEFAULT_SIDECAR_PORT = 8789
_DEFAULT_SIDECAR_BIND = "127.0.0.1"
# Photon iMessage messages from the SDK side have no documented hard
# limit, but the underlying iMessage protocol limits practical message
# size to ~16 KB. Keep a conservative cap that matches BlueBubbles.
_MAX_MESSAGE_LENGTH = 8000
# Spec says reject deliveries older than ~5 minutes for replay protection.
_TIMESTAMP_DRIFT_SECONDS = 300
# Dedup parameters — keep at least 1k IDs for ~48h per Photon's
# at-least-once guidance.
_DEDUP_MAX_SIZE = 4000
_DEDUP_WINDOW_SECONDS = 48 * 3600
_SIDECAR_DIR = Path(__file__).parent / "sidecar"
# Group-chat mention wake words. When ``require_mention`` is enabled, group
# messages are ignored unless they match one of these patterns — same
# behavior and defaults as the BlueBubbles iMessage channel so the two
# iMessage adapters gate group chats identically.
_DEFAULT_MENTION_PATTERNS = [
r"(?<![\w@])@?hermes\s+agent\b[,:\-]?",
r"(?<![\w@])@?hermes\b[,:\-]?",
]
# ---------------------------------------------------------------------------
# Module-level helpers — also used by check_fn / standalone send
def _coerce_port(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def check_requirements() -> bool:
"""Return True when both Python deps and the Node sidecar are available."""
if not HTTPX_AVAILABLE or not AIOHTTP_AVAILABLE:
return False
if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"):
return False
if not (_SIDECAR_DIR / "node_modules").exists():
# spectrum-ts not installed yet — `hermes photon setup` will
# install it. check_fn still returns False so the gateway
# surfaces the missing-deps state in `hermes setup` / status.
return False
return True
def validate_config(cfg: PlatformConfig) -> bool:
extra = cfg.extra or {}
project_id = extra.get("project_id") or os.getenv("PHOTON_PROJECT_ID")
project_secret = extra.get("project_secret") or os.getenv("PHOTON_PROJECT_SECRET")
if not project_id or not project_secret:
# Fall back to auth.json
stored_id, stored_sec = load_project_credentials()
return bool(stored_id and stored_sec)
return True
def is_connected(cfg: PlatformConfig) -> bool:
return validate_config(cfg)
def _env_enablement() -> Optional[dict]:
"""Seed PlatformConfig.extra from env so env-only setups appear in status."""
project_id, project_secret = load_project_credentials()
if not (project_id and project_secret):
return None
return {
"project_id": project_id,
"project_secret": project_secret,
"webhook_port": _coerce_port(os.getenv("PHOTON_WEBHOOK_PORT"), _DEFAULT_WEBHOOK_PORT),
"webhook_path": os.getenv("PHOTON_WEBHOOK_PATH") or _DEFAULT_WEBHOOK_PATH,
}
# ---------------------------------------------------------------------------
# Signature verification
def verify_signature(
*,
body: bytes,
timestamp_header: str,
signature_header: str,
signing_secret: str,
now: Optional[float] = None,
drift: int = _TIMESTAMP_DRIFT_SECONDS,
) -> bool:
"""Constant-time verify a Photon webhook signature.
Returns True iff the timestamp is within ``drift`` of *now* AND
``signature_header == "v0=" + hmac_sha256(secret, "v0:{ts}:{body}")``.
Exposed at module scope so tests can exercise it without an adapter
instance.
"""
if not timestamp_header or not signature_header or not signing_secret:
return False
try:
ts = int(timestamp_header)
except ValueError:
return False
if abs((now or time.time()) - ts) > drift:
return False
if not signature_header.startswith("v0="):
return False
expected = hmac.new(
signing_secret.encode("utf-8"),
f"v0:{ts}:".encode("utf-8") + body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header[3:])
# ---------------------------------------------------------------------------
# Adapter
class PhotonAdapter(BasePlatformAdapter):
"""Inbound: signed webhook on aiohttp. Outbound: Node sidecar via loopback HTTP."""
MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform("photon"))
extra = config.extra or {}
# Project credentials (env wins, then config.extra, then auth.json).
stored_id, stored_sec = load_project_credentials()
self._project_id: str = (
os.getenv("PHOTON_PROJECT_ID")
or extra.get("project_id")
or stored_id
or ""
)
self._project_secret: str = (
os.getenv("PHOTON_PROJECT_SECRET")
or extra.get("project_secret")
or stored_sec
or ""
)
# Webhook receiver
self._webhook_port = _coerce_port(
extra.get("webhook_port") or os.getenv("PHOTON_WEBHOOK_PORT"),
_DEFAULT_WEBHOOK_PORT,
)
self._webhook_path = (
extra.get("webhook_path")
or os.getenv("PHOTON_WEBHOOK_PATH")
or _DEFAULT_WEBHOOK_PATH
)
self._webhook_bind = (
extra.get("webhook_bind")
or os.getenv("PHOTON_WEBHOOK_BIND")
or _DEFAULT_WEBHOOK_BIND
)
self._webhook_secret: str = (
os.getenv("PHOTON_WEBHOOK_SECRET")
or extra.get("webhook_secret")
or ""
)
# Sidecar
self._sidecar_port = _coerce_port(
extra.get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"),
_DEFAULT_SIDECAR_PORT,
)
self._sidecar_bind = _DEFAULT_SIDECAR_BIND
self._sidecar_token = (
os.getenv("PHOTON_SIDECAR_TOKEN") or secrets.token_hex(16)
)
self._autostart_sidecar = str(
os.getenv("PHOTON_SIDECAR_AUTOSTART", "true")
).lower() not in ("0", "false", "no")
self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node"
# Runtime state
self._runner: Optional["web.AppRunner"] = None
self._sidecar_proc: Optional[subprocess.Popen] = None
self._sidecar_supervisor_task: Optional[asyncio.Task] = None
self._http_client: Optional["httpx.AsyncClient"] = None
# Lightweight in-memory dedup. Photon's at-least-once guarantee
# means we WILL see the same message.id more than once.
self._seen_messages: Dict[str, float] = {}
# Group-chat mention gating (parity with BlueBubbles). When enabled,
# group messages are ignored unless they match a wake word; DMs are
# always processed. Config key wins, then env var.
_require_mention = extra.get("require_mention")
if _require_mention is None:
_require_mention = os.getenv("PHOTON_REQUIRE_MENTION")
self.require_mention = str(_require_mention).strip().lower() in {
"true", "1", "yes", "on",
}
self._mention_patterns = self._compile_mention_patterns(
extra["mention_patterns"]
if "mention_patterns" in extra
else os.getenv("PHOTON_MENTION_PATTERNS")
)
# -- Group-mention gating (parity with BlueBubbles) -------------------
@staticmethod
def _compile_mention_patterns(raw: Any) -> "list[re.Pattern]":
"""Compile group-mention wake words from config/env.
``raw`` is a list (config or env JSON), a string (env var: JSON
list, or comma/newline-separated), or None (use Hermes defaults).
Mirrors the BlueBubbles implementation so both iMessage channels
accept the same configuration shapes.
"""
if raw is None:
patterns = list(_DEFAULT_MENTION_PATTERNS)
elif isinstance(raw, str):
text = raw.strip()
try:
loaded = json.loads(text) if text else []
except Exception:
loaded = None
patterns = loaded if isinstance(loaded, list) else [
part.strip()
for line in text.splitlines()
for part in line.split(",")
]
elif isinstance(raw, list):
patterns = raw
else:
patterns = [raw]
compiled: "list[re.Pattern]" = []
for pattern in patterns:
text = str(pattern).strip()
if not text:
continue
try:
compiled.append(re.compile(text, re.IGNORECASE))
except re.error as exc:
logger.warning("[photon] Invalid mention pattern %r: %s", text, exc)
return compiled
def _message_matches_mention_patterns(self, text: str) -> bool:
if not text or not self._mention_patterns:
return False
return any(pattern.search(text) for pattern in self._mention_patterns)
def _clean_mention_text(self, text: str) -> str:
"""Strip a leading wake word before dispatch.
Custom mention patterns are regexes, so we only strip a leading
match to avoid deleting ordinary words later in the prompt.
"""
if not text:
return text
for pattern in self._mention_patterns:
match = pattern.match(text.lstrip())
if match:
cleaned = text.lstrip()[match.end():].lstrip(" ,:-")
return cleaned or text
return text
# -- Connection lifecycle ---------------------------------------------
async def connect(self) -> bool:
if not AIOHTTP_AVAILABLE:
self._set_fatal_error(
"MISSING_DEP",
"aiohttp not installed. Run: pip install aiohttp",
retryable=False,
)
return False
if not HTTPX_AVAILABLE:
self._set_fatal_error(
"MISSING_DEP", "httpx not installed", retryable=False
)
return False
if not self._project_id or not self._project_secret:
self._set_fatal_error(
"MISSING_CREDENTIALS",
"PHOTON_PROJECT_ID and PHOTON_PROJECT_SECRET are required. "
"Run: hermes photon setup",
retryable=False,
)
return False
# Start the aiohttp receiver first; without it the sidecar would
# be able to forward inbound traffic to a closed port.
try:
await self._start_webhook_server()
except OSError as e:
self._set_fatal_error(
"PORT_IN_USE",
f"webhook port {self._webhook_port} unavailable: {e}",
retryable=True,
)
return False
# Spin up the Node sidecar (required for outbound).
if self._autostart_sidecar:
try:
await self._start_sidecar()
except Exception as e:
self._set_fatal_error(
"SIDECAR_FAILED",
f"failed to start Photon sidecar: {e}",
retryable=True,
)
await self._stop_webhook_server()
return False
else:
logger.info("[photon] sidecar autostart disabled — outbound will fail")
self._http_client = httpx.AsyncClient(timeout=30.0)
self._mark_connected()
logger.info(
"[photon] connected — webhook at %s:%d%s, sidecar on %s:%d",
self._webhook_bind, self._webhook_port, self._webhook_path,
self._sidecar_bind, self._sidecar_port,
)
return True
async def disconnect(self) -> None:
await self._stop_sidecar()
await self._stop_webhook_server()
if self._http_client is not None:
try:
await self._http_client.aclose()
except Exception:
pass
self._http_client = None
self._mark_disconnected()
# -- Webhook server ----------------------------------------------------
async def _start_webhook_server(self) -> None:
app = web.Application()
app.router.add_post(self._webhook_path, self._handle_webhook)
app.router.add_get("/healthz", lambda _: web.Response(text="ok"))
self._runner = web.AppRunner(app)
await self._runner.setup()
site = web.TCPSite(self._runner, self._webhook_bind, self._webhook_port)
await site.start()
async def _stop_webhook_server(self) -> None:
if self._runner is not None:
try:
await self._runner.cleanup()
except Exception:
pass
self._runner = None
async def _handle_webhook(self, request: "web.Request") -> "web.Response":
body = await request.read()
if self._webhook_secret:
ts = request.headers.get("X-Spectrum-Timestamp", "")
sig = request.headers.get("X-Spectrum-Signature", "")
if not verify_signature(
body=body,
timestamp_header=ts,
signature_header=sig,
signing_secret=self._webhook_secret,
):
logger.warning("[photon] rejected webhook with bad signature")
return web.Response(status=401, text="invalid signature")
else:
logger.warning(
"[photon] PHOTON_WEBHOOK_SECRET unset — accepting unsigned "
"deliveries. Set the per-URL signing secret returned by "
"register-webhook to enable verification."
)
try:
payload = json.loads(body or b"{}")
except json.JSONDecodeError:
return web.Response(status=400, text="invalid json")
if payload.get("event") != "messages":
# Photon currently emits only `messages`; any future event
# types are ack'd 200 so they don't retry.
return web.Response(text="ok")
msg = payload.get("message") or {}
msg_id = msg.get("id")
if not msg_id:
return web.Response(status=400, text="missing message.id")
if self._is_duplicate(msg_id):
return web.Response(text="ok (dup)")
try:
await self._dispatch_inbound(payload)
except Exception:
logger.exception("[photon] inbound dispatch failed")
# 200 anyway — we own the dedup; failing here would cause
# Photon to retry the same id.
return web.Response(text="ok")
def _is_duplicate(self, msg_id: str) -> bool:
now = time.time()
if len(self._seen_messages) > _DEDUP_MAX_SIZE:
cutoff = now - _DEDUP_WINDOW_SECONDS
self._seen_messages = {
k: v for k, v in self._seen_messages.items() if v > cutoff
}
if msg_id in self._seen_messages:
return True
self._seen_messages[msg_id] = now
return False
async def _dispatch_inbound(self, payload: Dict[str, Any]) -> None:
msg = payload.get("message") or {}
space = msg.get("space") or payload.get("space") or {}
sender = msg.get("sender") or {}
content = msg.get("content") or {}
space_id = space.get("id") or ""
sender_id = sender.get("id") or ""
if not space_id:
logger.warning("[photon] inbound missing space.id")
return
# Space type — Photon documents iMessage DM ids as `any;-;+E164`
# and group ids as `any;+;<chat-guid>`. Use that as the
# heuristic; everything else is treated as DM.
chat_type = "group" if ";+;" in space_id else "dm"
# Timestamp — ISO 8601 from the platform.
ts_str = msg.get("timestamp") or ""
try:
timestamp = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
except ValueError:
timestamp = datetime.now(tz=timezone.utc)
# Content normalization. Spectrum is a discriminated union;
# text vs attachment metadata. Attachments are metadata-only
# today (no download URL) — log + carry the name so the agent
# at least knows something was sent.
if content.get("type") == "text":
text = content.get("text") or ""
mtype = MessageType.TEXT
elif content.get("type") == "attachment":
name = content.get("name") or "(unnamed)"
mime = content.get("mimeType") or ""
text = f"[Photon attachment received: {name} ({mime}) — no download URL yet]"
mtype = _attachment_message_type(mime)
else:
text = f"[Photon content type not handled: {content.get('type')}]"
mtype = MessageType.TEXT
# Group-mention gating (parity with BlueBubbles). In group chats with
# require_mention enabled, drop messages that don't hit a wake word;
# strip the leading wake word from the ones that do. DMs are never
# gated.
if chat_type == "group" and self.require_mention:
if not self._message_matches_mention_patterns(text):
logger.debug(
"[photon] ignoring group message "
"(require_mention=true, no mention pattern matched)"
)
return
text = self._clean_mention_text(text)
source = self.build_source(
chat_id=space_id,
chat_name=space_id,
chat_type=chat_type,
user_id=sender_id or space_id,
user_name=sender_id or None,
)
event = MessageEvent(
text=text,
message_type=mtype,
source=source,
message_id=msg.get("id"),
raw_message=payload,
timestamp=timestamp,
)
await self.handle_message(event)
# -- Sidecar lifecycle -------------------------------------------------
async def _start_sidecar(self) -> None:
if not (_SIDECAR_DIR / "node_modules").exists():
raise RuntimeError(
f"Photon sidecar deps not installed. Run: "
f"cd {_SIDECAR_DIR} && npm install (or `hermes photon setup`)"
)
env = os.environ.copy()
env["PHOTON_PROJECT_ID"] = self._project_id
env["PHOTON_PROJECT_SECRET"] = self._project_secret
env["PHOTON_SIDECAR_PORT"] = str(self._sidecar_port)
env["PHOTON_SIDECAR_BIND"] = self._sidecar_bind
env["PHOTON_SIDECAR_TOKEN"] = self._sidecar_token
self._sidecar_proc = subprocess.Popen( # noqa: S603
[self._node_bin, str(_SIDECAR_DIR / "index.mjs")],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
start_new_session=(sys.platform != "win32"),
)
# Pump sidecar stderr/stdout into our logger so users see crashes.
loop = asyncio.get_event_loop()
self._sidecar_supervisor_task = loop.create_task(
self._supervise_sidecar(self._sidecar_proc)
)
# Wait for /healthz to come up — give it up to 15s on cold start.
deadline = time.time() + 15.0
last_err: Optional[Exception] = None
async with httpx.AsyncClient(timeout=2.0) as client:
while time.time() < deadline:
if self._sidecar_proc.poll() is not None:
raise RuntimeError(
f"Photon sidecar exited with code "
f"{self._sidecar_proc.returncode} before becoming ready"
)
try:
resp = await client.post(
f"http://{self._sidecar_bind}:{self._sidecar_port}/healthz",
headers={"X-Hermes-Sidecar-Token": self._sidecar_token},
)
if resp.status_code == 200:
return
except httpx.RequestError as e:
last_err = e
await asyncio.sleep(0.2)
raise RuntimeError(
f"Photon sidecar did not become ready within 15s: {last_err}"
)
async def _supervise_sidecar(self, proc: subprocess.Popen) -> None:
"""Pump the sidecar's stdout/stderr into our logger."""
if proc.stdout is None: # subprocess was launched without stdout=PIPE
return
stdout = proc.stdout
loop = asyncio.get_event_loop()
try:
while True:
line = await loop.run_in_executor(None, stdout.readline)
if not line:
break
logger.info("[photon-sidecar] %s", line.decode("utf-8", "replace").rstrip())
except Exception as e: # pragma: no cover - defensive
logger.warning("[photon-sidecar] supervisor exited: %s", e)
async def _stop_sidecar(self) -> None:
proc = self._sidecar_proc
if proc is None:
return
try:
# Polite shutdown first.
if self._http_client is not None:
try:
await self._http_client.post(
f"http://{self._sidecar_bind}:{self._sidecar_port}/shutdown",
headers={"X-Hermes-Sidecar-Token": self._sidecar_token},
timeout=2.0,
)
except Exception:
pass
try:
proc.wait(timeout=3.0)
except subprocess.TimeoutExpired:
if sys.platform != "win32":
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM) # windows-footgun: ok
except (ProcessLookupError, PermissionError):
proc.terminate()
else:
proc.terminate()
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
finally:
self._sidecar_proc = None
if self._sidecar_supervisor_task is not None:
self._sidecar_supervisor_task.cancel()
self._sidecar_supervisor_task = None
# -- Outbound ----------------------------------------------------------
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
return await self._sidecar_send(chat_id, content, reply_to=reply_to)
async def send_typing(self, chat_id: str, metadata=None) -> None:
try:
await self._sidecar_call("/typing", {"spaceId": chat_id})
except Exception as e:
logger.debug("[photon] send_typing failed: %s", e)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Return whatever we know about a Spectrum space id.
Photon's `space.id` is opaque (`any;-;+E164` for DMs,
`any;+;<guid>` for groups). We surface that shape directly so
the gateway has something to show in session pickers / logs.
"""
chat_type = "group" if ";+;" in chat_id else "dm"
return {"name": chat_id, "type": chat_type, "id": chat_id}
async def _sidecar_send(
self, space_id: str, text: str, *, reply_to: Optional[str] = None,
) -> SendResult:
if len(text) > self.MAX_MESSAGE_LENGTH:
logger.warning(
"[photon] truncating outbound from %d to %d chars",
len(text), self.MAX_MESSAGE_LENGTH,
)
text = text[: self.MAX_MESSAGE_LENGTH]
body: Dict[str, Any] = {"spaceId": space_id, "text": text}
if reply_to:
body["replyTo"] = reply_to
try:
data = await self._sidecar_call("/send", body)
except Exception as e:
return SendResult(success=False, error=str(e))
return SendResult(success=True, message_id=data.get("messageId"))
async def _sidecar_call(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
if self._http_client is None:
raise RuntimeError("Photon adapter not connected")
resp = await self._http_client.post(
f"http://{self._sidecar_bind}:{self._sidecar_port}{path}",
json=body,
headers={"X-Hermes-Sidecar-Token": self._sidecar_token},
timeout=30.0,
)
if resp.status_code != 200:
raise RuntimeError(
f"Photon sidecar {path} returned {resp.status_code}: {resp.text[:200]}"
)
data = resp.json() or {}
if not data.get("ok"):
raise RuntimeError(
f"Photon sidecar {path} reported error: {data.get('error')}"
)
return data
# ---------------------------------------------------------------------------
# Helpers
def _attachment_message_type(mime: str) -> MessageType:
mime = (mime or "").lower()
if mime.startswith("image/"):
return MessageType.PHOTO
if mime.startswith("video/"):
return MessageType.VIDEO
if mime.startswith("audio/"):
return MessageType.AUDIO
if mime.startswith("application/"):
return MessageType.DOCUMENT
return MessageType.DOCUMENT
# ---------------------------------------------------------------------------
# Standalone (out-of-process) send for cron deliveries when the gateway
# is not co-resident. Spins up an ephemeral sidecar call by spawning
# the existing sidecar binary one-shot; if a live sidecar is already
# listening on the configured port we reuse it.
async def _standalone_send(
pconfig: PlatformConfig,
chat_id: str,
message: str,
*,
thread_id: Optional[str] = None, # noqa: ARG001 — Spectrum has no threads yet
media_files: Optional[list] = None, # noqa: ARG001 — attachment send not supported yet
force_document: bool = False, # noqa: ARG001
) -> Dict[str, Any]:
if not HTTPX_AVAILABLE:
return {"error": "httpx not installed"}
port = _coerce_port(
(pconfig.extra or {}).get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"),
_DEFAULT_SIDECAR_PORT,
)
token = os.getenv("PHOTON_SIDECAR_TOKEN")
if not token:
return {
"error": (
"Photon standalone send requires a running sidecar with "
"PHOTON_SIDECAR_TOKEN set in the environment. Cron processes "
"cannot spawn the sidecar themselves."
)
}
body: Dict[str, Any] = {"spaceId": chat_id, "text": message[:_MAX_MESSAGE_LENGTH]}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"http://{_DEFAULT_SIDECAR_BIND}:{port}/send",
json=body,
headers={"X-Hermes-Sidecar-Token": token},
)
if resp.status_code != 200:
return {"error": f"sidecar returned {resp.status_code}: {resp.text[:200]}"}
data = resp.json() or {}
if not data.get("ok"):
return {"error": data.get("error") or "sidecar reported failure"}
return {"success": True, "message_id": data.get("messageId")}
except Exception as e:
return {"error": f"Photon standalone send failed: {e}"}
# ---------------------------------------------------------------------------
# Plugin entry point
def register(ctx) -> None:
"""Called by the Hermes plugin loader at startup."""
# Local import to avoid argparse work at module load; reused for both the
# gateway-setup hook and the `hermes photon` CLI command below.
from . import cli as _cli
ctx.register_platform(
name="photon",
label="Photon iMessage",
adapter_factory=lambda cfg: PhotonAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=["PHOTON_PROJECT_ID", "PHOTON_PROJECT_SECRET"],
install_hint=(
"Run: hermes photon setup (logs in via device flow, creates a "
"Spectrum project, links your phone number, installs the "
"spectrum-ts sidecar)."
),
# Surfaces Photon in `hermes gateway setup` alongside every other
# channel — same unified onboarding wizard, no Photon-only detour.
setup_fn=_cli.gateway_setup,
env_enablement_fn=_env_enablement,
cron_deliver_env_var="PHOTON_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
allowed_users_env="PHOTON_ALLOWED_USERS",
allow_all_env="PHOTON_ALLOW_ALL_USERS",
max_message_length=_MAX_MESSAGE_LENGTH,
emoji="📱",
# iMessage carries E.164 phone numbers — treat session descriptions
# as PII-sensitive so they get redacted before reaching the LLM
# (matches the BlueBubbles iMessage channel in _PII_SAFE_PLATFORMS).
pii_safe=True,
allow_update_command=True,
platform_hint=(
"You are communicating via Photon Spectrum (iMessage). "
"Treat replies like regular text messages — short, friendly, no "
"markdown rendering. Recipient identifiers are E.164 phone "
"numbers; never expose them in responses unless the user asked. "
"Attachments arrive as metadata only (no download URL yet)."
),
)
# Register CLI subcommands — `hermes photon ...`
ctx.register_cli_command(
name="photon",
help="Set up and manage the Photon iMessage integration",
setup_fn=_cli.register_cli,
handler_fn=_cli.dispatch,
)

View file

@ -0,0 +1,581 @@
"""
Photon Dashboard + Spectrum API client and device-code login flow.
This module is pure Python it intentionally does not depend on
``spectrum-ts``. All management-plane operations (login, create
project, create user, register webhook) talk to Photon's HTTP API
directly:
Dashboard API https://app.photon.codes/api/...
OAuth bearer token from device flow
Spectrum API https://spectrum.photon.codes/projects/{id}/...
HTTP Basic with (projectId, projectSecret)
The webhook receiver + Node sidecar in ``adapter.py`` consume the
credentials this module persists to ``~/.hermes/auth.json``.
Reference docs (read at integration time):
https://photon.codes/docs/api-reference/introduction
https://photon.codes/docs/api-reference/device-login/request-device-+-user-code
https://photon.codes/docs/api-reference/device-login/exchange-device-code-for-token
https://photon.codes/docs/api-reference/projects/create-project
https://photon.codes/docs/api-reference/users/create-user
https://photon.codes/docs/webhooks/overview
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Tuple
try:
import httpx
except ImportError: # pragma: no cover - httpx is a hermes dependency
httpx = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# Photon's published OAuth device-client identifier for first-party CLIs.
# We use a fixed "hermes-agent" client_id string — Photon's device endpoint
# accepts any opaque client_id and ties the bearer token to the approving
# user, not to the client. If Photon later requires registered clients,
# this is the one knob to update.
DEFAULT_CLIENT_ID = "hermes-agent"
DEFAULT_DASHBOARD_HOST = "https://app.photon.codes"
DEFAULT_SPECTRUM_HOST = "https://spectrum.photon.codes"
# Polling defaults per RFC 8628. Photon may override via `interval` /
# `expires_in` fields in the device-code response — those win.
DEFAULT_POLL_INTERVAL = 5
DEFAULT_POLL_TIMEOUT = 900 # 15 minutes is conservative; Photon returns expires_in
E164_RE = re.compile(r"^\+[1-9]\d{6,14}$")
# ---------------------------------------------------------------------------
# auth.json helpers — share the file with the rest of hermes-agent.
def _auth_json_path() -> Path:
"""Resolve ``~/.hermes/auth.json`` honouring the active Hermes profile."""
try:
from hermes_constants import get_hermes_home
return Path(get_hermes_home()) / "auth.json"
except Exception:
return Path(os.path.expanduser("~/.hermes")) / "auth.json"
def _load_auth() -> Dict[str, Any]:
path = _auth_json_path()
if not path.exists():
return {}
try:
with path.open("r", encoding="utf-8") as fh:
return json.load(fh) or {}
except (OSError, json.JSONDecodeError) as e:
logger.warning("photon: could not read %s: %s", path, e)
return {}
def _save_auth(data: Dict[str, Any]) -> None:
path = _auth_json_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with tmp.open("w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, sort_keys=True)
try:
os.chmod(tmp, 0o600)
except OSError:
pass
tmp.replace(path)
def load_photon_token() -> Optional[str]:
"""Return the bearer token stored by ``login()`` or ``None``."""
auth = _load_auth()
pool = auth.get("credential_pool", {}).get("photon") or []
if isinstance(pool, list) and pool:
token = pool[0].get("access_token") or pool[0].get("token")
if token:
return str(token)
# Backwards-compat shape: providers.photon.access_token
legacy = auth.get("providers", {}).get("photon", {})
if legacy.get("access_token"):
return str(legacy["access_token"])
return None
def store_photon_token(token: str) -> None:
"""Persist a dashboard bearer token under ``credential_pool.photon``."""
auth = _load_auth()
auth.setdefault("credential_pool", {})["photon"] = [
{"access_token": token, "issued_at": int(time.time())}
]
_save_auth(auth)
def load_project_credentials() -> Tuple[Optional[str], Optional[str]]:
"""Return ``(project_id, project_secret)`` from auth.json + env override."""
env_id = os.getenv("PHOTON_PROJECT_ID")
env_sec = os.getenv("PHOTON_PROJECT_SECRET")
if env_id and env_sec:
return env_id, env_sec
auth = _load_auth()
proj = auth.get("credential_pool", {}).get("photon_project") or []
if isinstance(proj, list) and proj:
entry = proj[0]
return (
env_id or entry.get("project_id"),
env_sec or entry.get("project_secret"),
)
return env_id, env_sec
def store_project_credentials(project_id: str, project_secret: str, **extra: Any) -> None:
"""Persist the Spectrum project's id+secret under ``credential_pool.photon_project``."""
auth = _load_auth()
record = {
"project_id": project_id,
"project_secret": project_secret,
"issued_at": int(time.time()),
}
record.update(extra)
auth.setdefault("credential_pool", {})["photon_project"] = [record]
_save_auth(auth)
# ---------------------------------------------------------------------------
# Device login flow (RFC 8628)
@dataclass
class DeviceCode:
device_code: str
user_code: str
verification_uri: str
verification_uri_complete: Optional[str]
expires_in: int
interval: int
def _dashboard_host() -> str:
return (os.getenv("PHOTON_DASHBOARD_HOST") or DEFAULT_DASHBOARD_HOST).rstrip("/")
def _spectrum_host() -> str:
return (os.getenv("PHOTON_API_HOST") or DEFAULT_SPECTRUM_HOST).rstrip("/")
def request_device_code(
*, client_id: str = DEFAULT_CLIENT_ID, scope: Optional[str] = None,
) -> DeviceCode:
"""POST ``/api/auth/device/code`` and return the device + user codes."""
if httpx is None:
raise RuntimeError("httpx is required for Photon device login")
url = f"{_dashboard_host()}/api/auth/device/code"
body: Dict[str, Any] = {"client_id": client_id}
if scope:
body["scope"] = scope
resp = httpx.post(url, json=body, timeout=30.0)
resp.raise_for_status()
data = resp.json()
return DeviceCode(
device_code=data["device_code"],
user_code=data["user_code"],
verification_uri=data["verification_uri"],
verification_uri_complete=data.get("verification_uri_complete"),
expires_in=int(data.get("expires_in") or DEFAULT_POLL_TIMEOUT),
interval=int(data.get("interval") or DEFAULT_POLL_INTERVAL),
)
def poll_for_token(
code: DeviceCode,
*,
client_id: str = DEFAULT_CLIENT_ID,
timeout: Optional[int] = None,
interval: Optional[int] = None,
on_pending: Optional[Callable[[], None]] = None,
) -> str:
"""Poll ``/api/auth/device/token`` until the user approves.
Returns the bearer token from the ``set-auth-token`` response header
(Photon's documented mechanism). Falls back to ``session.access_token``
in the JSON body if the header is absent see the API spec.
"""
if httpx is None:
raise RuntimeError("httpx is required for Photon device login")
url = f"{_dashboard_host()}/api/auth/device/token"
deadline = time.time() + (timeout or code.expires_in or DEFAULT_POLL_TIMEOUT)
sleep = interval or code.interval or DEFAULT_POLL_INTERVAL
while time.time() < deadline:
try:
resp = httpx.post(
url,
json={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": code.device_code,
"client_id": client_id,
},
timeout=30.0,
)
except httpx.RequestError as e:
logger.warning("photon: device-token poll failed: %s", e)
time.sleep(sleep)
continue
if resp.status_code == 200:
token = resp.headers.get("set-auth-token")
if not token:
body = resp.json() or {}
session = body.get("session") or {}
token = session.get("access_token") or body.get("access_token")
if not token:
raise RuntimeError(
"Photon returned 200 but no token in headers or body"
)
return token
if resp.status_code == 400:
# RFC 8628 §3.5 — error codes are returned with 400.
body: Dict[str, Any] = {}
try:
body = resp.json() or {}
except json.JSONDecodeError:
pass
err = body.get("error") or body.get("message") or ""
if err in ("authorization_pending", "slow_down"):
if on_pending:
try:
on_pending()
except Exception:
pass
if err == "slow_down":
sleep += 5
time.sleep(sleep)
continue
if err in ("expired_token", "access_denied"):
raise RuntimeError(f"Photon login failed: {err}")
# Unknown error — surface it
raise RuntimeError(f"Photon device token error: {err or resp.text}")
# Unexpected status; log and retry
logger.warning(
"photon: device-token unexpected status %s: %s",
resp.status_code, resp.text[:200],
)
time.sleep(sleep)
raise TimeoutError("Photon device login timed out")
def login_device_flow(
*,
client_id: str = DEFAULT_CLIENT_ID,
open_browser: bool = True,
on_user_code: Optional[Callable[["DeviceCode"], None]] = None,
) -> str:
"""Run the full device-code login flow and persist the token.
Returns the bearer token. ``on_user_code`` is a callback receiving the
:class:`DeviceCode` so callers can print + optionally open the browser.
"""
code = request_device_code(client_id=client_id)
if on_user_code:
try:
on_user_code(code)
except Exception:
pass
if open_browser:
try:
import webbrowser
target = code.verification_uri_complete or code.verification_uri
webbrowser.open(target, new=2)
except Exception:
pass
token = poll_for_token(code, client_id=client_id)
store_photon_token(token)
return token
# ---------------------------------------------------------------------------
# Dashboard API: create project
def create_project(
token: str,
*,
name: str,
location: str = "United States",
platforms: Optional[list] = None,
) -> Dict[str, Any]:
"""POST ``/api/projects/`` with ``spectrum: true`` and return the response.
The response includes ``spectrumProjectId`` and ``projectSecret`` those
are the HTTP Basic credentials for the Spectrum API. Photon only
returns ``projectSecret`` to project owners at creation time.
"""
if httpx is None:
raise RuntimeError("httpx is required for Photon project creation")
url = f"{_dashboard_host()}/api/projects/"
body: Dict[str, Any] = {
"name": name,
"location": location,
"spectrum": True,
"platforms": platforms or ["imessage"],
}
resp = httpx.post(
url,
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
)
resp.raise_for_status()
return resp.json()
# ---------------------------------------------------------------------------
# Spectrum API: create user
def create_user(
project_id: str,
project_secret: str,
*,
phone_number: str,
user_type: str = "shared",
first_name: Optional[str] = None,
last_name: Optional[str] = None,
email: Optional[str] = None,
assigned_phone_number: Optional[str] = None,
) -> Dict[str, Any]:
"""POST ``/projects/{id}/users/`` on the Spectrum API.
For free users we always pass ``type=shared``; Photon's Cosmos pool
assigns the iMessage line. ``assigned_phone_number`` is only valid
for the paid ``dedicated`` mode.
"""
if httpx is None:
raise RuntimeError("httpx is required for Photon user creation")
if not E164_RE.match(phone_number):
raise ValueError(
f"phone_number must be E.164 (e.g. +15551234567); got {phone_number!r}"
)
url = f"{_spectrum_host()}/projects/{project_id}/users/"
body: Dict[str, Any] = {"type": user_type, "phoneNumber": phone_number}
if first_name:
body["firstName"] = first_name
if last_name:
body["lastName"] = last_name
if email:
body["email"] = email
if assigned_phone_number:
body["assignedPhoneNumber"] = assigned_phone_number
resp = httpx.post(
url,
json=body,
auth=(project_id, project_secret),
timeout=30.0,
)
resp.raise_for_status()
data = resp.json() or {}
if not data.get("succeed"):
raise RuntimeError(
f"Photon create-user failed: {data.get('message') or data}"
)
return data.get("data") or {}
# ---------------------------------------------------------------------------
# Spectrum API: webhook registration
#
# Endpoints from https://photon.codes/docs/webhooks/overview:
# POST /projects/{id}/webhooks/ register, returns signing secret ONCE
# GET /projects/{id}/webhooks/ list
# DELETE /projects/{id}/webhooks/{wid} remove
def register_webhook(
project_id: str, project_secret: str, *, webhook_url: str,
) -> Dict[str, Any]:
"""Register a webhook URL with Photon and return the API response.
Photon returns the per-URL signing secret exactly once in this
response, so callers who need to persist it should hand the
response to :func:`persist_webhook_signing_secret` immediately
that helper writes the value into ``~/.hermes/.env`` (mode 0o600,
existing entries preserved) without the secret value ever needing
to leave this module.
"""
if httpx is None:
raise RuntimeError("httpx is required for Photon webhook registration")
url = f"{_spectrum_host()}/projects/{project_id}/webhooks/"
resp = httpx.post(
url,
json={"webhookUrl": webhook_url},
auth=(project_id, project_secret),
timeout=30.0,
)
resp.raise_for_status()
data = resp.json() or {}
if not data.get("succeed"):
raise RuntimeError(
f"Photon register-webhook failed: {data.get('message') or data}"
)
return data.get("data") or {}
def print_credential_summary(emit: Any = print) -> None:
"""Pretty-print the credential status table via the *emit* callback.
Same isolation rationale as :func:`persist_webhook_signing_secret`:
all secret-bearing reads happen inside this function; the *emit*
callback only ever receives display literals like ``"✓ stored"``
or a project UUID. No tainted variable ever escapes into the
caller's scope. Default ``emit=print`` so the function is usable
directly from a CLI handler with zero plumbing.
"""
# Resolve every credential read into a plain display string FIRST,
# in a tight block. The intermediate `labels` dict only ever stores
# literals from a finite set ("✓ stored" / "✗ missing" / "✓ set" /
# "⚠ unset — verification disabled" / a project UUID) — never a
# credential's raw bytes. We then assemble the whole banner into
# one string and call emit() exactly once with that string, so the
# static taint analyzer sees a single sink that consumes only a
# joined literal blob.
labels: Dict[str, str] = {}
if load_photon_token():
labels["device_token"] = "✓ stored"
else:
labels["device_token"] = "✗ missing (run `hermes photon setup`)"
pid, sec = load_project_credentials()
labels["project_id"] = pid if pid else "✗ missing"
labels["project_key"] = "✓ stored" if sec else "✗ missing"
if os.getenv("PHOTON_WEBHOOK_SECRET"):
labels["webhook_key"] = "✓ set"
else:
labels["webhook_key"] = "⚠ unset — verification disabled"
rows = [
"Photon iMessage status",
"──────────────────────",
" device token : " + labels["device_token"],
" project id : " + labels["project_id"],
" project key : " + labels["project_key"],
" webhook key : " + labels["webhook_key"],
]
emit("\n".join(rows))
def credential_summary() -> Dict[str, str]:
"""Return a fully pre-formatted credential status dict.
Caller-safe: every value is one of ``"✓ stored"`` / ``"✗ missing"``
/ ``"⚠ unset — verification disabled"`` / ``"✓ set"`` literals, or a
UUID for the project id. No secret-bearing string ever leaves this
function read-and-bool-cast happens entirely inside the closure.
"""
def _present_token() -> str:
return "✓ stored" if load_photon_token() else "✗ missing (run `hermes photon setup`)"
def _present_project_id() -> str:
pid, _sec = load_project_credentials()
return pid or "✗ missing"
def _present_project_secret() -> str:
_pid, sec = load_project_credentials()
return "✓ stored" if sec else "✗ missing"
def _present_webhook_secret() -> str:
return "✓ set" if os.getenv("PHOTON_WEBHOOK_SECRET") else "⚠ unset — verification disabled"
return {
"device_token": _present_token(),
"project_id": _present_project_id(),
"project_key": _present_project_secret(),
"webhook_key": _present_webhook_secret(),
}
def persist_webhook_signing_secret(
webhook_data: Dict[str, Any],
*,
on_summary: Optional[Any] = None,
) -> bool:
"""Persist a webhook signing secret via Hermes' canonical .env writer.
Delegates to :func:`hermes_cli.config.save_env_value` the same
helper that backs every other API-key persistence path in Hermes
Agent (OpenAI key, Anthropic key, Telegram token, ...). The secret
value is read directly from ``webhook_data['signingSecret']`` (or
``['secret']`` fallback) and handed to that helper without ever
being bound to a local in any module that prints or logs.
Returns ``True`` on success, ``False`` if the response had no
secret OR the write failed. The optional ``on_summary`` callable
receives a plain string with no credential material, suitable for
printing e.g. ``"Wrote to /home/u/.hermes/.env"`` or
``"register response: {redacted dict json}"``. We do the
formatting here so callers stay clear of the taint flow CodeQL
tracks through functions that touch secrets.
"""
if not isinstance(webhook_data, dict):
return False
has_secret = bool(webhook_data.get("signingSecret") or webhook_data.get("secret"))
redacted = {
k: ("<redacted>" if k in ("signingSecret", "secret") else v)
for k, v in webhook_data.items()
}
if on_summary is not None:
try:
on_summary("webhook registration response (redacted):")
on_summary(json.dumps(redacted, indent=2))
except Exception:
pass
if not has_secret:
return False
try:
from hermes_cli.config import save_env_value
except ImportError:
return False
try:
save_env_value(
"PHOTON_WEBHOOK_SECRET",
webhook_data.get("signingSecret") or webhook_data.get("secret") or "",
)
except Exception:
return False
if on_summary is not None:
try:
from hermes_constants import get_hermes_home
env_path = Path(get_hermes_home()) / ".env"
except Exception:
env_path = Path(os.path.expanduser("~/.hermes")) / ".env"
try:
on_summary(f"signing key saved to {env_path}")
on_summary("(Photon only returns this once — keep the file safe)")
except Exception:
pass
return True
def list_webhooks(project_id: str, project_secret: str) -> list:
if httpx is None:
raise RuntimeError("httpx is required for Photon webhook listing")
url = f"{_spectrum_host()}/projects/{project_id}/webhooks/"
resp = httpx.get(url, auth=(project_id, project_secret), timeout=30.0)
resp.raise_for_status()
data = resp.json() or {}
return data.get("data") or []
def delete_webhook(
project_id: str, project_secret: str, *, webhook_id: str,
) -> None:
if httpx is None:
raise RuntimeError("httpx is required for Photon webhook deletion")
url = f"{_spectrum_host()}/projects/{project_id}/webhooks/{webhook_id}"
resp = httpx.delete(url, auth=(project_id, project_secret), timeout=30.0)
if resp.status_code not in (200, 204, 404):
resp.raise_for_status()

View file

@ -0,0 +1,340 @@
"""
``hermes photon ...`` CLI subcommands registered by the plugin via
``ctx.register_cli_command()``.
Subcommands:
setup full first-time setup (device login + project + user + sidecar)
status show login + project + sidecar dep state
install-sidecar npm install inside plugins/platforms/photon/sidecar/
webhook register register the local webhook URL with Photon
webhook list list registered webhooks
webhook delete delete a webhook by id
The device-code login runs automatically as the first step of ``setup``;
there is no standalone ``login`` verb (matching how every other Hermes
gateway channel onboards through a single setup surface).
"""
from __future__ import annotations
import argparse
import getpass
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from . import auth as photon_auth
_SIDECAR_DIR = Path(__file__).parent / "sidecar"
# ---------------------------------------------------------------------------
# argparse wiring
def register_cli(parser: argparse.ArgumentParser) -> None:
"""Wire up `hermes photon ...` subcommands."""
subs = parser.add_subparsers(dest="photon_command", required=False)
p_setup = subs.add_parser("setup", help="First-time setup (device login + project + user + sidecar)")
p_setup.add_argument("--project-name", default=None, help="Project name (default: 'Hermes Agent')")
p_setup.add_argument("--phone", default=None, help="Your E.164 phone number (e.g. +15551234567)")
p_setup.add_argument("--first-name", default=None)
p_setup.add_argument("--last-name", default=None)
p_setup.add_argument("--email", default=None)
p_setup.add_argument("--no-browser", action="store_true",
help="Don't try to open a browser for device login; print the URL only")
p_setup.add_argument("--skip-sidecar-install", action="store_true",
help="Skip `npm install` inside the sidecar directory")
subs.add_parser("status", help="Show login + project + sidecar dep state")
subs.add_parser("install-sidecar", help="Run npm install inside the sidecar directory")
p_hook = subs.add_parser("webhook", help="Manage Photon webhook registrations")
hook_subs = p_hook.add_subparsers(dest="photon_webhook_command", required=True)
p_hook_reg = hook_subs.add_parser("register", help="Register a webhook URL")
p_hook_reg.add_argument("url", help="Publicly reachable URL Photon should POST to")
hook_subs.add_parser("list", help="List registered webhooks for the current project")
p_hook_del = hook_subs.add_parser("delete", help="Delete a webhook by id")
p_hook_del.add_argument("webhook_id")
parser.set_defaults(func=dispatch)
# ---------------------------------------------------------------------------
# Dispatch
def dispatch(args: argparse.Namespace) -> int:
sub = getattr(args, "photon_command", None)
if sub is None:
# No subcommand given — show status by default.
return _cmd_status(args)
if sub == "setup":
return _cmd_setup(args)
if sub == "status":
return _cmd_status(args)
if sub == "install-sidecar":
return _cmd_install_sidecar(args)
if sub == "webhook":
return _cmd_webhook(args)
print(f"unknown subcommand: {sub}", file=sys.stderr)
return 2
# ---------------------------------------------------------------------------
# Subcommand handlers
def _run_device_login(args: argparse.Namespace) -> int:
"""Run the RFC 8628 device-code login flow and persist the token.
Internal helper invoked as the first step of ``setup``. There is
no standalone ``hermes photon login`` command; Photon onboards
through the single ``setup`` surface like every other channel.
"""
def _print_code(code):
target = code.verification_uri_complete or code.verification_uri
print()
print("┌─ Photon device login ────────────────────────────────────────")
print(f"│ Open this URL: {target}")
print(f"│ Enter the code: {code.user_code}")
print("│ (waiting for approval — Ctrl-C to cancel)")
print("└──────────────────────────────────────────────────────────────")
print()
try:
token = photon_auth.login_device_flow(
open_browser=not args.no_browser,
on_user_code=_print_code,
)
except Exception as e:
print(f"login failed: {e}", file=sys.stderr)
return 1
# Don't print any portion of the token — even a prefix can help a
# shoulder-surfer or accidentally leak into a screen recording.
_ = token
print(f"✓ logged in — token saved to {photon_auth._auth_json_path()}")
return 0
def _cmd_setup(args: argparse.Namespace) -> int:
# 1. Login (skip if we already have a token).
token = photon_auth.load_photon_token()
if not token:
print("[1/4] No Photon token found — running device login...")
rc = _run_device_login(args)
if rc != 0:
return rc
token = photon_auth.load_photon_token()
if not token:
print("login completed but token was not stored", file=sys.stderr)
return 1
else:
print("[1/4] Reusing existing Photon token")
# 2. Create (or surface existing) project.
existing_id, existing_secret = photon_auth.load_project_credentials()
project_id: str
project_secret: str
if existing_id and existing_secret:
project_id, project_secret = existing_id, existing_secret
# `project_id` is a Photon-assigned UUID, not a secret — but we
# keep the print terse to avoid CodeQL flow noise.
print("[2/4] Reusing existing Photon project")
else:
name = args.project_name or "Hermes Agent"
print(f"[2/4] Creating Photon project '{name}' (spectrum=true, imessage)...")
try:
data = photon_auth.create_project(token, name=name)
except Exception as e:
print(f"create-project failed: {e}", file=sys.stderr)
return 1
project_id = data.get("spectrumProjectId") or data.get("id") or ""
project_secret = data.get("projectSecret") or ""
if not project_id or not project_secret:
print(
"create-project did not return spectrumProjectId + "
"projectSecret. Re-run after enabling Spectrum on the "
"project, or open https://app.photon.codes/ to fetch the "
"secret manually.",
file=sys.stderr,
)
return 1
photon_auth.store_project_credentials(project_id, project_secret, name=name)
print(" ✓ project provisioned (run `hermes photon status` to see the id)")
# 3. Create a Spectrum user for the operator.
phone = args.phone or _prompt(
"Your iMessage phone number (E.164, e.g. +15551234567): "
)
if not phone:
print("[3/4] Skipped user creation (no phone given). Re-run with --phone later.")
else:
print("[3/4] Creating shared Spectrum user...")
try:
photon_auth.create_user(
project_id, project_secret,
phone_number=phone,
first_name=args.first_name,
last_name=args.last_name,
email=args.email,
)
except Exception as e:
print(f"create-user failed: {e}", file=sys.stderr)
return 1
print(" ✓ user created — check `hermes photon status` or the dashboard for the assigned iMessage line")
# 4. Sidecar deps.
if args.skip_sidecar_install:
print("[4/4] Skipping sidecar npm install (--skip-sidecar-install)")
else:
print("[4/4] Installing Node sidecar deps (spectrum-ts)...")
rc = _install_sidecar()
if rc != 0:
return rc
print()
print("✓ Photon setup complete.")
print(" Next: register a webhook URL Photon can reach:")
print(" hermes photon webhook register https://YOUR-PUBLIC-URL/photon/webhook")
print(" Then start the gateway:")
print(" hermes gateway start --platform photon")
return 0
def _cmd_status(_args: argparse.Namespace) -> int:
# Defer the whole table 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.
photon_auth.print_credential_summary(print)
# The two non-credential rows live here so the helper stays purely
# about credentials.
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
sidecar_installed = (_SIDECAR_DIR / "node_modules").exists()
print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}")
print(f" sidecar deps : {'✓ installed' if sidecar_installed else '✗ run `hermes photon install-sidecar`'}")
return 0
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
rc = _install_sidecar()
return rc
def _install_sidecar() -> int:
npm = shutil.which("npm") or "npm"
if not shutil.which(npm):
print(
"npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) "
"and re-run.",
file=sys.stderr,
)
return 1
print(f" $ cd {_SIDECAR_DIR} && {npm} install")
proc = subprocess.run( # noqa: S603
[npm, "install"],
cwd=str(_SIDECAR_DIR),
check=False,
)
if proc.returncode != 0:
print("npm install failed", file=sys.stderr)
return proc.returncode
def _cmd_webhook(args: argparse.Namespace) -> int:
sub = getattr(args, "photon_webhook_command", None)
project_id, project_secret = photon_auth.load_project_credentials()
if not (project_id and project_secret):
print(
"no Photon project configured — run `hermes photon setup` first",
file=sys.stderr,
)
return 1
if sub == "register":
try:
data = photon_auth.register_webhook(
project_id, project_secret, webhook_url=args.url
)
except Exception as e:
print(f"register failed: {e}", file=sys.stderr)
return 1
# The helper does all the formatting + writing; cli.py never
# touches the signing-secret value, the path it was written
# to, or even the redacted-response dict. on_summary is a
# plain printer callback.
ok = photon_auth.persist_webhook_signing_secret(data, on_summary=print)
if not ok:
print(
"‼ Photon returned no signing secret in the response, "
"or the file write failed. Inspect your home directory "
"permissions and re-run; do not retry without first "
"deleting the orphaned webhook from the Photon dashboard.",
file=sys.stderr,
)
return 1
return 0
if sub == "list":
try:
data = photon_auth.list_webhooks(project_id, project_secret)
except Exception as e:
print(f"list failed: {e}", file=sys.stderr)
return 1
print(json.dumps(data, indent=2))
return 0
if sub == "delete":
try:
photon_auth.delete_webhook(
project_id, project_secret, webhook_id=args.webhook_id
)
except Exception as e:
print(f"delete failed: {e}", file=sys.stderr)
return 1
print(f"deleted webhook {args.webhook_id}")
return 0
print(f"unknown webhook subcommand: {sub}", file=sys.stderr)
return 2
# ---------------------------------------------------------------------------
# Gateway-setup entry point
#
# `hermes gateway setup` discovers platforms via the registry and calls each
# entry's zero-arg ``setup_fn``. Photon registers this function so it appears
# in the unified setup wizard alongside every other channel — same onboarding
# surface, no Photon-specific detour. It runs the identical device-login +
# project + user + sidecar flow as ``hermes photon setup`` with interactive
# defaults (phone is prompted when stdin is a TTY).
def gateway_setup() -> None:
"""Run Photon first-time setup from the `hermes gateway setup` wizard."""
args = argparse.Namespace(
photon_command="setup",
project_name=None,
phone=None,
first_name=None,
last_name=None,
email=None,
no_browser=False,
skip_sidecar_install=False,
)
_cmd_setup(args)
# ---------------------------------------------------------------------------
# Small interactive helpers
def _prompt(prompt: str, *, secret: bool = False) -> str:
if not sys.stdin.isatty():
return ""
try:
if secret:
return getpass.getpass(prompt).strip()
return input(prompt).strip()
except (KeyboardInterrupt, EOFError):
print()
return ""

View file

@ -0,0 +1,91 @@
name: photon-platform
label: Photon iMessage
kind: platform
version: 0.1.0
description: >
Photon Spectrum gateway adapter for Hermes Agent.
Connects to iMessage (and other Spectrum interfaces) through Photon's
managed Spectrum platform. Inbound messages arrive as signed webhooks
on a local aiohttp server; outbound messages are sent via a small
supervised Node sidecar that runs the `spectrum-ts` SDK (Photon does
not currently expose a public HTTP send endpoint).
The plugin ships with a `hermes photon` CLI for the one-time login
+ project + user setup, persists Spectrum credentials to
``~/.hermes/auth.json`` under ``credential_pool.photon`` (token) and
``credential_pool.photon_project`` (project id + secret), and exposes
Photon's free shared-line model so users can get started without a
paid plan.
author: NousResearch
requires_env:
- name: PHOTON_PROJECT_ID
description: "Spectrum project ID (set by `hermes photon setup`)"
prompt: "Photon Spectrum project ID"
url: "https://app.photon.codes/"
password: false
- name: PHOTON_PROJECT_SECRET
description: "Spectrum project secret (set by `hermes photon setup`)"
prompt: "Photon Spectrum project secret"
url: "https://app.photon.codes/"
password: true
optional_env:
- name: PHOTON_WEBHOOK_SECRET
description: "Per-URL HMAC-SHA256 signing secret returned at webhook registration"
prompt: "Photon webhook signing secret"
password: true
- name: PHOTON_WEBHOOK_PORT
description: "Local port the webhook receiver listens on (default 8788)"
prompt: "Webhook receiver port"
password: false
- name: PHOTON_WEBHOOK_PATH
description: "Path the webhook receiver listens on (default /photon/webhook)"
prompt: "Webhook receiver path"
password: false
- name: PHOTON_WEBHOOK_BIND
description: "Bind address for the webhook receiver (default 0.0.0.0)"
prompt: "Webhook bind address"
password: false
- name: PHOTON_SIDECAR_PORT
description: "Loopback port for the Node sidecar control channel (default 8789)"
prompt: "Sidecar control port"
password: false
- name: PHOTON_SIDECAR_AUTOSTART
description: "Spawn the Node sidecar on connect (true/false, default true)"
prompt: "Auto-start the sidecar?"
password: false
- name: PHOTON_NODE_BIN
description: "Path to the node binary (default: shutil.which('node'))"
prompt: "Node executable path"
password: false
- name: PHOTON_API_HOST
description: "Spectrum management API host (default https://spectrum.photon.codes)"
prompt: "Spectrum API host"
password: false
- name: PHOTON_DASHBOARD_HOST
description: "Dashboard API host (default https://app.photon.codes)"
prompt: "Dashboard 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)"
password: false
- name: PHOTON_ALLOW_ALL_USERS
description: "Allow any sender to trigger the bot (dev only — disables allowlist)"
prompt: "Allow all users? (true/false)"
password: false
- name: PHOTON_REQUIRE_MENTION
description: "Ignore group-chat messages unless they match a mention wake word (true/false, default false)"
prompt: "Require a mention in group chats?"
password: false
- name: PHOTON_MENTION_PATTERNS
description: "Mention wake-word regexes for group chats (JSON list or comma/newline-separated; defaults to Hermes wake words)"
prompt: "Group mention patterns"
password: false
- name: PHOTON_HOME_CHANNEL
description: "Default Spectrum space ID for cron / notification delivery"
prompt: "Home space ID"
password: false
- name: PHOTON_HOME_CHANNEL_NAME
description: "Human label for the home channel"
prompt: "Home channel display name"
password: false

View file

@ -0,0 +1,52 @@
# Photon sidecar
Small Node helper that bridges Hermes Agent to Photon's Spectrum SDK
(`spectrum-ts`). Hermes is Python; Photon has no public HTTP
send-message endpoint today; replies therefore go through this sidecar.
The sidecar:
- runs `Spectrum({ projectId, projectSecret, providers: [imessage.config()] })`
- exposes a loopback-only HTTP control channel for the Python adapter
to push send/typing requests (auth via `X-Hermes-Sidecar-Token`)
- drains the inbound message stream so `spectrum-ts` keeps its
reconnect/heartbeat machinery alive (real inbound delivery is via
Photon's signed webhook hitting our Python aiohttp server)
## Install
```bash
cd plugins/platforms/photon/sidecar
npm install
```
The Hermes plugin's `hermes photon setup` command runs `npm install`
here automatically.
## Run standalone
For debugging:
```bash
PHOTON_PROJECT_ID=... PHOTON_PROJECT_SECRET=... \
PHOTON_SIDECAR_PORT=8789 PHOTON_SIDECAR_TOKEN=$(openssl rand -hex 16) \
node index.mjs
```
In normal use, the Python adapter supervises this process — start,
restart on crash, kill on shutdown — and never asks the user to run
it by hand.
## Why a sidecar at all?
Photon publishes webhooks (inbound) but their docs state explicitly:
> Pass `space.id` to `Space.send(...)` from a separate `spectrum-ts`
> SDK instance to reply. No public HTTP send endpoint exists today.
— https://photon.codes/docs/webhooks/events
When Photon ships an HTTP send endpoint, the plan is to retire this
sidecar entirely and call it directly from Python. The plugin's
outbound code path is already isolated behind a single helper
(`_sidecar_send` in `adapter.py`) to make that swap a one-file change.

View file

@ -0,0 +1,226 @@
// Hermes Agent — Photon Spectrum sidecar
//
// Spawned by `plugins/platforms/photon/adapter.py` to bridge outbound
// messaging to Photon's Spectrum platform. Inbound messages go directly
// from Photon's webhook to Hermes' Python aiohttp receiver — this
// sidecar handles ONLY outbound calls (which require the spectrum-ts
// SDK because Photon has no public HTTP send endpoint today).
//
// Protocol:
// - The sidecar listens on http://127.0.0.1:${PORT} (loopback only)
// - Each request must include `X-Hermes-Sidecar-Token: ${TOKEN}`
// - POST /healthz -> {"ok": true}
// - POST /send -> {"ok": true, "messageId": "..."}
// body: {"spaceId": "...", "text": "...", "replyTo": "..." | null}
// - POST /typing -> {"ok": true}
// body: {"spaceId": "..."}
// - POST /shutdown -> {"ok": true}; then process exits
//
// On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before
// exiting. Errors are logged to stderr; Python supervises restart.
//
// Env vars (all required):
// PHOTON_PROJECT_ID
// PHOTON_PROJECT_SECRET
// PHOTON_SIDECAR_PORT
// PHOTON_SIDECAR_TOKEN
//
// Optional:
// PHOTON_SIDECAR_BIND (default 127.0.0.1)
// PHOTON_API_HOST (passed through to spectrum-ts if its config
// honours it)
import http from "node:http";
const projectId = process.env.PHOTON_PROJECT_ID;
const projectSecret = process.env.PHOTON_PROJECT_SECRET;
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;
if (!projectId || !projectSecret || !sharedToken) {
console.error(
"photon-sidecar: PHOTON_PROJECT_ID, PHOTON_PROJECT_SECRET and " +
"PHOTON_SIDECAR_TOKEN must all be set."
);
process.exit(2);
}
// Lazy-load spectrum-ts so a missing install fails with a clear message
// instead of a cryptic module-resolution error during import.
let Spectrum, imessage;
try {
({ Spectrum } = await import("spectrum-ts"));
({ imessage } = await import("spectrum-ts/providers/imessage"));
} catch (e) {
console.error(
"photon-sidecar: spectrum-ts is not installed. Run `npm install` " +
"inside plugins/platforms/photon/sidecar/. Original error: " +
(e && e.stack ? e.stack : String(e))
);
process.exit(3);
}
const app = await Spectrum({
projectId,
projectSecret,
providers: [imessage.config()],
});
// Drain the inbound stream — Photon's webhook is the canonical inbound
// path, but we still consume `app.messages` so spectrum-ts' internal
// reconnect/heartbeat logic keeps running. Each event is logged at
// debug level; everything else is a no-op here.
(async () => {
try {
for await (const [, message] of app.messages) {
console.error(
`photon-sidecar: drained inbound from ${message.platform} ` +
`space=${message.space?.id}`
);
}
} catch (e) {
console.error(
"photon-sidecar: inbound stream errored: " +
(e && e.stack ? e.stack : String(e))
);
}
})();
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString("utf-8");
if (!raw) return {};
try {
return JSON.parse(raw);
} catch (e) {
throw new Error("invalid JSON body");
}
}
function unauthorized(res) {
res.statusCode = 401;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
}
function badRequest(res, msg) {
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: false, error: msg }));
}
function serverError(res) {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
// Don't leak stack traces or raw exception text to the caller — even
// though we listen on loopback, the supervisor logs the real error
// and the client only needs a generic failure signal.
res.end(JSON.stringify({ ok: false, error: "internal sidecar error" }));
}
function ok(res, data) {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: true, ...data }));
}
async function resolveSpace(spaceId) {
// spectrum-ts exposes the same Space methods via `app.space(spaceId)` /
// narrowed helpers; we fall back through a few accessor shapes to
// tolerate small SDK API drift.
if (typeof app.space === "function") {
return await app.space(spaceId);
}
if (app.spaces && typeof app.spaces.get === "function") {
return await app.spaces.get(spaceId);
}
// Last resort — the platform-narrowed helper.
if (imessage) {
const im = imessage(app);
if (typeof im.space === "function") {
try {
return await im.space({ id: spaceId });
} catch {
/* fall through */
}
}
}
throw new Error(`unable to resolve space id ${spaceId}`);
}
const server = http.createServer(async (req, res) => {
if (req.headers["x-hermes-sidecar-token"] !== sharedToken) {
return unauthorized(res);
}
if (req.method !== "POST") {
res.statusCode = 405;
return res.end();
}
try {
if (req.url === "/healthz") {
return ok(res, {});
}
if (req.url === "/shutdown") {
ok(res, {});
setTimeout(() => process.kill(process.pid, "SIGTERM"), 50);
return;
}
const body = await readBody(req);
if (req.url === "/send") {
const { spaceId, text, replyTo } = body || {};
if (!spaceId || typeof text !== "string") {
return badRequest(res, "spaceId and text are required");
}
const space = await resolveSpace(spaceId);
const result = replyTo
? await space.send(text, { replyTo })
: await space.send(text);
return ok(res, { messageId: result?.id || result?.messageId || null });
}
if (req.url === "/typing") {
const { spaceId } = body || {};
if (!spaceId) return badRequest(res, "spaceId is required");
const space = await resolveSpace(spaceId);
if (typeof space.typing === "function") {
await space.typing();
} else if (typeof space.setTyping === "function") {
await space.setTyping(true);
}
return ok(res, {});
}
res.statusCode = 404;
res.setHeader("Content-Type", "application/json");
return res.end(JSON.stringify({ ok: false, error: "not found" }));
} catch (e) {
console.error(
"photon-sidecar: handler error: " +
(e && e.stack ? e.stack : String(e))
);
// serverError() intentionally returns a generic message — see its
// body for the rationale.
return serverError(res);
}
});
server.listen(port, bind, () => {
console.error(`photon-sidecar: listening on ${bind}:${port}`);
});
async function shutdown(signal) {
console.error(`photon-sidecar: received ${signal}, stopping...`);
try {
await Promise.race([
app.stop(),
new Promise((resolve) => setTimeout(resolve, 3000)),
]);
} catch (e) {
console.error("photon-sidecar: app.stop() failed: " + String(e));
}
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 500).unref();
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));

View file

@ -0,0 +1,17 @@
{
"name": "@hermes-agent/photon-sidecar",
"private": true,
"version": "0.1.0",
"description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.",
"type": "module",
"main": "index.mjs",
"scripts": {
"start": "node index.mjs"
},
"engines": {
"node": ">=18.17"
},
"dependencies": {
"spectrum-ts": "^0.1.0"
}
}

View file

@ -104,7 +104,7 @@ dependencies = [
[project.optional-dependencies]
# Native Anthropic provider — only needed when provider=anthropic (not via
# OpenRouter or other aggregators).
anthropic = ["anthropic==0.86.0"]
anthropic = ["anthropic==0.87.0"] # CVE-2026-34450, CVE-2026-34452
# Web search backends — each only loaded when the user picks it as their
# search provider (configured via `hermes tools` or config.yaml).
exa = ["exa-py==2.10.2"]
@ -119,9 +119,9 @@ modal = ["modal==1.3.4"]
daytona = ["daytona==0.155.0"]
hindsight = ["hindsight-client==0.6.1"]
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] # starlette: CVE-2026-48710
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.4", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525
cron = [] # croniter is now a core dependency; this extra kept for back-compat
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"]
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.4"]
matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"]
# WeCom callback-mode adapter — parses untrusted XML POST bodies from
# WeCom-controlled callback endpoints, so we use defusedxml (drop-in
@ -160,8 +160,8 @@ vision = []
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
nemo-relay = ["nemo-relay==0.3"]
homeassistant = ["aiohttp==3.13.3"]
sms = ["aiohttp==3.13.3"]
homeassistant = ["aiohttp==3.13.4"]
sms = ["aiohttp==3.13.4"]
# Computer use — macOS background desktop control via cua-driver (MCP stdio).
# The cua-driver binary itself is installed via `hermes tools` post-setup
# (curl install script); this extra just pins the MCP client used to talk

View file

@ -358,6 +358,7 @@ class AIAgent:
save_trajectories: bool = False,
verbose_logging: bool = False,
quiet_mode: bool = False,
tool_progress_mode: str = "all",
ephemeral_system_prompt: str = None,
log_prefix_chars: int = 100,
log_prefix: str = "",
@ -430,6 +431,7 @@ class AIAgent:
save_trajectories=save_trajectories,
verbose_logging=verbose_logging,
quiet_mode=quiet_mode,
tool_progress_mode=tool_progress_mode,
ephemeral_system_prompt=ephemeral_system_prompt,
log_prefix_chars=log_prefix_chars,
log_prefix=log_prefix,

View file

@ -1130,7 +1130,7 @@ function Install-Repository {
git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName"
if ($LASTEXITCODE -eq 0) { $autostashRef = "stash@{0}" }
}
git -c windows.appendAtomically=false fetch origin
git -c windows.appendAtomically=false fetch origin $Branch
if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" }
# Precedence: Commit > Tag > Branch. Commit and Tag check
# out as detached HEAD intentionally -- they're meant to be

View file

@ -1118,7 +1118,12 @@ clone_repo() {
autostash_ref="stash@{0}"
fi
git fetch origin
# Fetch only the target branch. A bare `git fetch origin` pulls
# every ref, and this repo carries thousands of auto-generated
# branches — on a non-single-branch checkout that turns each update
# into a multi-minute download that can stall the installer.
git remote set-branches origin "$BRANCH" 2>/dev/null || true
git fetch origin "$BRANCH"
git checkout "$BRANCH"
git pull --ff-only origin "$BRANCH"

View file

@ -66,8 +66,10 @@ AUTHOR_MAP = {
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"dirtyren@users.noreply.github.com": "dirtyren",
"ted.malone@outlook.com": "temalo",
"adityamalik2833@gmail.com": "alarcritty",
"islam666@users.noreply.github.com": "islam666",
"mnajafian@nvidia.com": "mnajafian-nv",
"25539605+lsaether@users.noreply.github.com": "lsaether",
"30080538+JimStenstrom@users.noreply.github.com": "JimStenstrom",
"rod.boev@gmail.com": "rodboev",
@ -1089,6 +1091,7 @@ AUTHOR_MAP = {
"holynn@placeholder.local": "holynn-q",
"agent@hermes.local": "jacdevos",
"sunsky.lau@gmail.com": "liuhao1024",
"rob@rbrtbn.com": "rbrtbn",
"haaasined@gmail.com": "VinciZhu",
"fabianoeq@gmail.com": "rodrigoeqnit",
"178342791+sgtworkman@users.noreply.github.com": "sgtworkman",
@ -1489,6 +1492,8 @@ AUTHOR_MAP = {
"leonard@sellem.me": "leonardsellem", # PR #37405 (desktop WS origin guard on remote/Tailscale binds)
"42903577+ohMyJason@users.noreply.github.com": "ohMyJason", # PR #29810 (discover_models in custom_providers section 4)
"singhsanidhya741@gmail.com": "sanidhyasin", # PR #40403 salvage (model.default_headers for custom OpenAI-compatible providers, #40033)
"josephjohnson.joel@gmail.com": "JoelJJohnson", # PR #39913 salvage (Windows ConPTY dashboard chat bridge)
"andreas@schwarz-ketsch.de": "Nea74", # PR #40022 co-author credit (same Windows ConPTY bridge design)
}

View file

@ -335,6 +335,50 @@ def _run_one_file(
# dead processes are a no-op.
_kill_tree(proc, pgid=pgid)
if rc == 4 and Path(file).exists():
# pytest exit 4 = "file or directory not found" at exec time, yet the
# file is present on disk now. On loaded shared CI runners we have seen
# the planner enumerate a file (its tests counted via --collect-only)
# but the per-file subprocess fail to stat it moments later — a
# transient the deterministic LPT slicer otherwise reproduces on every
# rerun (same file set → same shard). Retry the file ONCE before
# surfacing it as a hard failure. We do NOT widen the exit-5 rule:
# exit 4 on a file that genuinely does not exist must still fail.
retry_proc = subprocess.Popen(
cmd,
cwd=repo_root,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
retry_pgid: int | None = None
if sys.platform != "win32":
try:
retry_pgid = os.getpgid(retry_proc.pid)
except (ProcessLookupError, PermissionError):
retry_pgid = None
try:
retry_output, _ = retry_proc.communicate(timeout=file_timeout)
retry_rc = retry_proc.returncode
except subprocess.TimeoutExpired:
_kill_tree(retry_proc, pgid=retry_pgid)
try:
retry_output, _ = retry_proc.communicate(timeout=10)
except subprocess.TimeoutExpired:
retry_output = "(file timeout exceeded on retry; output unavailable)"
retry_rc = 124
retry_output = (
f"(per-file timeout on exit-4 retry: {file_timeout:.0f}s exceeded; "
f"process tree SIGKILL'd)\n{retry_output}"
)
except BaseException:
_kill_tree(retry_proc, pgid=retry_pgid)
raise
else:
_kill_tree(retry_proc, pgid=retry_pgid)
rc, output = retry_rc, retry_output
if rc == 5:
# No tests collected — every test in the file was filtered out.
# Treat as a pass; surface info in a slightly distinct status

View file

@ -0,0 +1,241 @@
"""Tests for #42039 — user messages stored twice in state.db.
When the agent has its own SessionDB reference (``_session_db is not None``),
``_flush_messages_to_session_db()`` persists messages to SQLite during the
agent run. The gateway's ``append_to_transcript()`` must then use
``skip_db=True`` on all fallback paths to prevent writing a second copy
to the same SQLite file.
This test covers the two fallback paths that previously lacked
``skip_db=agent_persisted``:
1. ``agent_failed_early`` path transient 429/timeout failures
2. ``not new_messages`` path edge case where ``history_offset`` exceeds
the actual message count
"""
import sys
import types
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
import gateway.run as gateway_run
from gateway.config import GatewayConfig, Platform
from gateway.platforms.base import MessageEvent
from gateway.session import SessionEntry, SessionSource
def _bootstrap(monkeypatch, tmp_path):
"""Minimal GatewayRunner setup shared by all tests in this module."""
fake_dotenv = types.ModuleType("dotenv")
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
config = GatewayConfig()
runner = gateway_run.GatewayRunner(config)
runner.adapters = {}
runner._running_agents = {}
runner._running_agents_ts = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._handle_active_session_busy_message = AsyncMock(return_value=False)
runner._session_db = MagicMock()
runner._recover_telegram_topic_thread_id = lambda _source: None
runner._cache_session_source = lambda _key, _source: None
runner._is_session_run_current = lambda _key, _gen: True
runner._begin_session_run_generation = lambda _key: 1
runner._reply_anchor_for_event = lambda _event: None
runner._get_guild_id = lambda _event: None
runner._should_send_voice_reply = lambda *_a, **_kw: False
runner.hooks = MagicMock()
runner.hooks.emit = AsyncMock()
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = SessionEntry(
session_key="agent:main:telegram:group:-1001:12345",
session_id="sess-dedup",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="group",
)
runner.session_store.load_transcript.return_value = []
runner.session_store.append_to_transcript = MagicMock()
runner.session_store.update_session = MagicMock()
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"}
)
monkeypatch.setattr(
"agent.model_metadata.get_model_context_length",
lambda *_args, **_kwargs: 100_000,
)
return runner
def _event():
return MessageEvent(
text="hello world",
source=SessionSource(
platform=Platform.TELEGRAM,
chat_id="-1001",
chat_type="group",
user_id="12345",
),
message_id="msg-42",
)
def _source():
return SessionSource(
platform=Platform.TELEGRAM,
chat_id="-1001",
chat_type="group",
user_id="12345",
)
def _assert_user_call_has_skip_db(calls, expected_skip_db: bool):
"""Find append_to_transcript calls with role='user' and check skip_db."""
user_calls = []
for call in calls:
args = call.args
if len(args) >= 2 and isinstance(args[1], dict):
if args[1].get("role") == "user":
user_calls.append(call)
assert len(user_calls) >= 1, (
f"Expected at least one user-role append_to_transcript call, "
f"got calls: {[c.args for c in calls if len(c.args)>=2]}"
)
for call in user_calls:
actual = call.kwargs.get("skip_db", False)
assert actual == expected_skip_db, (
f"Expected skip_db={expected_skip_db} for user-role call, "
f"got skip_db={actual}. kwargs={call.kwargs}"
)
# ── Test 1: agent_failed_early path uses skip_db=True ─────────────────
@pytest.mark.asyncio
async def test_agent_failed_early_skip_db_when_agent_has_session_db(
monkeypatch, tmp_path
):
runner = _bootstrap(monkeypatch, tmp_path)
# Agent fails with transient 429
runner._run_agent = AsyncMock(
return_value={
"failed": True,
"final_response": None,
"error": "429 Too Many Requests — rate limit exceeded",
"messages": [],
"history_offset": 0,
"last_prompt_tokens": 0,
}
)
await runner._handle_message_with_agent(
_event(), _source(), "agent:main:telegram:group:-1001:12345", 1
)
_assert_user_call_has_skip_db(
runner.session_store.append_to_transcript.call_args_list, True
)
# ── Test 2: agent_failed_early with no _session_db → skip_db not True ─
@pytest.mark.asyncio
async def test_agent_failed_early_no_skip_db_when_no_session_db(
monkeypatch, tmp_path
):
runner = _bootstrap(monkeypatch, tmp_path)
runner._session_db = None # No agent DB → agent_persisted=False
runner._run_agent = AsyncMock(
return_value={
"failed": True,
"final_response": None,
"error": "ReadTimeout: timed out",
"messages": [],
"history_offset": 0,
"last_prompt_tokens": 0,
}
)
await runner._handle_message_with_agent(
_event(), _source(), "agent:main:telegram:group:-1001:12345", 1
)
_assert_user_call_has_skip_db(
runner.session_store.append_to_transcript.call_args_list, False
)
# ── Test 3: not-new-messages path uses skip_db=True ───────────────────
@pytest.mark.asyncio
async def test_not_new_messages_skip_db_when_agent_has_session_db(
monkeypatch, tmp_path
):
runner = _bootstrap(monkeypatch, tmp_path)
# Agent succeeds but history_offset equals messages length → no new messages
runner._run_agent = AsyncMock(
return_value={
"final_response": "Hello!",
"messages": [{"role": "user", "content": "hi"}],
"tools": [],
"history_offset": 1, # equals len(messages) → new_messages=[]
"last_prompt_tokens": 0,
}
)
await runner._handle_message_with_agent(
_event(), _source(), "agent:main:telegram:group:-1001:12345", 1
)
_assert_user_call_has_skip_db(
runner.session_store.append_to_transcript.call_args_list, True
)
# ── Test 4: normal path (new_messages found) uses skip_db=True ────────
@pytest.mark.asyncio
async def test_normal_path_skip_db_when_agent_has_session_db(
monkeypatch, tmp_path
):
runner = _bootstrap(monkeypatch, tmp_path)
# Agent succeeds with new messages
runner._run_agent = AsyncMock(
return_value={
"final_response": "Hello!",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "Hello!"},
],
"tools": [],
"history_offset": 0,
"last_prompt_tokens": 0,
}
)
await runner._handle_message_with_agent(
_event(), _source(), "agent:main:telegram:group:-1001:12345", 1
)
_assert_user_call_has_skip_db(
runner.session_store.append_to_transcript.call_args_list, True
)

View file

@ -301,19 +301,23 @@ def test_save_codex_tokens_syncs_credential_pool(tmp_path, monkeypatch):
def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatch):
"""Re-auth must also refresh ``manual:device_code`` pool entries.
"""Re-auth must refresh ``manual:device_code`` entries that are true
aliases of the singleton, while leaving INDEPENDENT entries alone.
Regression for #33538: a user who hit #33000 before the #33164 fix landed
would have run ``hermes auth add openai-codex`` as a workaround, leaving
a pool entry with ``source="manual:device_code"``. On every subsequent
re-auth via setup/model picker, the singleton-seeded ``device_code`` entry
got refreshed but the ``manual:device_code`` entry stayed stale, recreating
the same 401 token_invalidated symptom that #33164 was supposed to fix.
Original regression for #33538: a user who hit #33000 before the #33164
fix landed would have run ``hermes auth add openai-codex`` as a
workaround, leaving a pool entry with ``source="manual:device_code"``.
On every subsequent re-auth via setup/model picker, the singleton-seeded
``device_code`` entry got refreshed but the ``manual:device_code`` entry
stayed stale, recreating the same 401 token_invalidated symptom that
#33164 was supposed to fix.
An interactive Codex device-code re-auth proves the user owns the ChatGPT
account, so it is safe to refresh every device-code-backed entry in the
pool but NOT independent ``manual:api_key`` entries (separate accounts /
explicit API keys).
Narrowed for #39236: the original fix treated every ``manual:device_code``
entry as a singleton-alias and refreshed them all, which silently
clobbered independent accounts added via ``hermes auth add openai-codex``.
The current behavior refreshes only entries whose access_token matches
the *previous* singleton access_token (true legacy aliases), and leaves
distinct-token entries alone (independent accounts).
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
@ -335,16 +339,30 @@ def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatc
"access_token": "old-at",
"refresh_token": "old-rt",
},
# Legacy alias from the #33000 workaround era — its tokens
# match the singleton, so it is a true alias and SHOULD be
# refreshed (preserves #33538 behavior).
{
"id": "auth-add",
"id": "legacy-alias",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "stale-manual-at",
"refresh_token": "stale-manual-rt",
"access_token": "old-at",
"refresh_token": "old-rt",
"last_status": "exhausted",
"last_error_code": 401,
"last_error_reason": "token_invalidated",
},
# Independent account from `hermes auth add openai-codex` —
# its tokens are distinct from the singleton. Must NOT be
# overwritten by a re-auth that targeted a different account
# (#39236).
{
"id": "independent",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "independent-at",
"refresh_token": "independent-rt",
},
{
"id": "api-key",
"source": "manual:api_key",
@ -363,18 +381,23 @@ def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatc
pool = auth["credential_pool"]["openai-codex"]
# Singleton-seeded device_code entry: refreshed and error markers cleared.
seeded = next(e for e in pool if e["source"] == "device_code")
seeded = next(e for e in pool if e["id"] == "seeded")
assert seeded["access_token"] == "fresh-at"
assert seeded["refresh_token"] == "fresh-rt"
# manual:device_code entry: ALSO refreshed (the new behavior).
manual_dc = next(e for e in pool if e["source"] == "manual:device_code")
assert manual_dc["access_token"] == "fresh-at"
assert manual_dc["refresh_token"] == "fresh-rt"
assert manual_dc["last_refresh"] == "2026-05-28T00:00:00Z"
assert manual_dc["last_status"] is None
assert manual_dc["last_error_code"] is None
assert manual_dc["last_error_reason"] is None
# Legacy alias (tokens matched previous singleton): ALSO refreshed.
legacy = next(e for e in pool if e["id"] == "legacy-alias")
assert legacy["access_token"] == "fresh-at"
assert legacy["refresh_token"] == "fresh-rt"
assert legacy["last_refresh"] == "2026-05-28T00:00:00Z"
assert legacy["last_status"] is None
assert legacy["last_error_code"] is None
assert legacy["last_error_reason"] is None
# Independent manual:device_code entry: NOT overwritten (#39236).
independent = next(e for e in pool if e["id"] == "independent")
assert independent["access_token"] == "independent-at"
assert independent["refresh_token"] == "independent-rt"
# manual:api_key entry: untouched — independent credential.
api_key = next(e for e in pool if e["source"] == "manual:api_key")
@ -382,6 +405,333 @@ def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatc
assert "refresh_token" not in api_key or api_key.get("refresh_token") is None
def test_save_codex_tokens_does_not_overwrite_independent_manual_entries(tmp_path, monkeypatch):
"""Re-auth must NOT overwrite ``manual:device_code`` entries that hold
independent token material (different OpenAI/ChatGPT accounts).
Regression for #39236: ``hermes auth add openai-codex`` for accounts B and C
routes through ``_save_codex_tokens`` because the singleton path is the
only Codex OAuth save flow. The #33538 fix refreshed every
``manual:device_code`` entry on every re-auth, which works fine for the
one-account/legacy-workaround case but silently overwrote distinct
independent accounts with the latest-authenticated tokens (labels
preserved, token material clobbered, status/quota readings then lie).
The safe invariant: an entry is a singleton-alias only when its current
access_token matches the *previous* singleton access_token. Manual
entries whose tokens never matched the singleton are independent accounts
and must be left alone.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
# Old singleton tokens — represent "account A" which the user
# logged in with via setup originally.
"tokens": {"access_token": "acctA-at", "refresh_token": "acctA-rt"},
"last_refresh": "2026-01-01T00:00:00Z",
"auth_mode": "chatgpt",
"label": "account-A",
},
},
"credential_pool": {
"openai-codex": [
# The seeded singleton mirror of account A.
{
"id": "seeded",
"label": "account-A",
"source": "device_code",
"auth_type": "oauth",
"access_token": "acctA-at",
"refresh_token": "acctA-rt",
},
# Two INDEPENDENT manual entries added later via
# ``hermes auth add openai-codex`` (account B and account C).
# Each has its OWN distinct token material, unrelated to the
# singleton.
{
"id": "acctB",
"label": "account-B",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "acctB-at",
"refresh_token": "acctB-rt",
},
{
"id": "acctC",
"label": "account-C",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "acctC-at",
"refresh_token": "acctC-rt",
},
],
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# User re-authenticates account A — fresh device-code login produces new
# tokens. The legitimate update is the seeded singleton mirror; the
# independent acctB/acctC entries must be untouched.
_save_codex_tokens(
{"access_token": "acctA-new-at", "refresh_token": "acctA-new-rt"},
last_refresh="2026-06-05T00:00:00Z",
)
auth = json.loads((hermes_home / "auth.json").read_text())
pool = auth["credential_pool"]["openai-codex"]
# Singleton-seeded entry: refreshed (legitimate sync).
seeded = next(e for e in pool if e["source"] == "device_code")
assert seeded["access_token"] == "acctA-new-at"
assert seeded["refresh_token"] == "acctA-new-rt"
assert seeded["last_refresh"] == "2026-06-05T00:00:00Z"
# acctB: INDEPENDENT entry — must NOT be overwritten.
acctB = next(e for e in pool if e["id"] == "acctB")
assert acctB["access_token"] == "acctB-at", (
"acctB was clobbered by acctA re-auth (#39236 regression)"
)
assert acctB["refresh_token"] == "acctB-rt"
# acctC: INDEPENDENT entry — must NOT be overwritten.
acctC = next(e for e in pool if e["id"] == "acctC")
assert acctC["access_token"] == "acctC-at", (
"acctC was clobbered by acctA re-auth (#39236 regression)"
)
assert acctC["refresh_token"] == "acctC-rt"
def test_save_codex_tokens_still_refreshes_legacy_manual_alias(tmp_path, monkeypatch):
"""The #33538 legacy use case must keep working.
A user who hit #33000 before the #33164 fix landed might have run
``hermes auth add openai-codex`` as a workaround when there was no
singleton entry that created a ``manual:device_code`` pool entry that
holds the SAME token material as the (later) singleton. This entry is a
true alias of the singleton and SHOULD still be refreshed on subsequent
re-auths, otherwise it goes stale and recreates the #33538 symptom.
The distinguishing signal: a legacy alias has access_token == previous
singleton access_token; an independent account does not.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"access_token": "shared-at", "refresh_token": "shared-rt"},
"last_refresh": "2026-01-01T00:00:00Z",
"auth_mode": "chatgpt",
},
},
"credential_pool": {
"openai-codex": [
{
"id": "seeded",
"source": "device_code",
"auth_type": "oauth",
"access_token": "shared-at",
"refresh_token": "shared-rt",
},
{
"id": "legacy",
"label": "legacy-alias",
"source": "manual:device_code",
"auth_type": "oauth",
# Token material matches the singleton — this is a true
# alias from the #33000 workaround era.
"access_token": "shared-at",
"refresh_token": "shared-rt",
"last_status": "exhausted",
"last_error_code": 401,
"last_error_reason": "token_invalidated",
},
],
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_save_codex_tokens(
{"access_token": "fresh-at", "refresh_token": "fresh-rt"},
last_refresh="2026-06-05T00:00:00Z",
)
auth = json.loads((hermes_home / "auth.json").read_text())
pool = auth["credential_pool"]["openai-codex"]
# Singleton: refreshed.
seeded = next(e for e in pool if e["source"] == "device_code")
assert seeded["access_token"] == "fresh-at"
# Legacy alias: still refreshed (preserves #33538 fix).
legacy = next(e for e in pool if e["id"] == "legacy")
assert legacy["access_token"] == "fresh-at"
assert legacy["refresh_token"] == "fresh-rt"
assert legacy["last_refresh"] == "2026-06-05T00:00:00Z"
# Error markers cleared on the refreshed entry.
assert legacy["last_status"] is None
assert legacy["last_error_code"] is None
assert legacy["last_error_reason"] is None
def test_save_codex_tokens_handles_missing_previous_singleton_tokens(tmp_path, monkeypatch):
"""First-ever Codex save (no prior singleton tokens) must not crash.
Edge case: a user has only pool entries (e.g. via direct auth.json edit
or a partial state from a corrupted upgrade), no `providers.openai-codex.tokens`
block at all. The previous-singleton-tokens guard must handle missing
state gracefully fall back to "no previous tokens", which means no
pool entry can be a true alias and only the singleton-seeded entry gets
written.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {},
"credential_pool": {
"openai-codex": [
{
"id": "preexisting",
"label": "pre-existing-manual",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "preexisting-at",
"refresh_token": "preexisting-rt",
},
],
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_save_codex_tokens(
{"access_token": "first-at", "refresh_token": "first-rt"},
last_refresh="2026-06-05T00:00:00Z",
)
auth = json.loads((hermes_home / "auth.json").read_text())
pool = auth["credential_pool"]["openai-codex"]
# Pre-existing independent entry with no relationship to a (now-new)
# singleton MUST be preserved.
pre = next(e for e in pool if e["id"] == "preexisting")
assert pre["access_token"] == "preexisting-at"
assert pre["refresh_token"] == "preexisting-rt"
def test_save_codex_tokens_alias_match_uses_access_token_only(tmp_path, monkeypatch):
"""A manual entry counts as an alias if its access_token matches the
previous singleton access_token, regardless of refresh_token presence.
Some legacy entries (older auth.json schemas, pre-refresh-token versions)
have access_token but no refresh_token. These should still be treated as
aliases when the access_token matches.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"access_token": "shared-at", "refresh_token": "shared-rt"},
"auth_mode": "chatgpt",
},
},
"credential_pool": {
"openai-codex": [
{
"id": "alias-no-refresh",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "shared-at",
# No refresh_token at all — legacy schema.
},
],
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_save_codex_tokens(
{"access_token": "new-at", "refresh_token": "new-rt"},
last_refresh="2026-06-05T00:00:00Z",
)
auth = json.loads((hermes_home / "auth.json").read_text())
pool = auth["credential_pool"]["openai-codex"]
alias = next(e for e in pool if e["id"] == "alias-no-refresh")
# Treated as alias → refreshed with new tokens.
assert alias["access_token"] == "new-at"
assert alias["refresh_token"] == "new-rt"
def test_save_codex_tokens_clears_error_markers_only_on_refreshed_entries(tmp_path, monkeypatch):
"""Error markers must be cleared only on entries that were actually
refreshed by this re-auth. Independent ``manual:device_code`` entries
with their own stale-error markers must be left alone (their stale state
is not the current re-auth's business).
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"access_token": "acctA-at", "refresh_token": "acctA-rt"},
"auth_mode": "chatgpt",
},
},
"credential_pool": {
"openai-codex": [
{
"id": "seeded",
"source": "device_code",
"auth_type": "oauth",
"access_token": "acctA-at",
"refresh_token": "acctA-rt",
"last_status": "exhausted",
"last_error_code": 401,
},
{
"id": "acctB",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "acctB-at",
"refresh_token": "acctB-rt",
"last_status": "exhausted",
"last_error_code": 429,
"last_error_reason": "quota_exhausted",
},
],
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_save_codex_tokens(
{"access_token": "fresh-at", "refresh_token": "fresh-rt"},
last_refresh="2026-06-05T00:00:00Z",
)
auth = json.loads((hermes_home / "auth.json").read_text())
pool = auth["credential_pool"]["openai-codex"]
# Singleton: refreshed AND error markers cleared.
seeded = next(e for e in pool if e["id"] == "seeded")
assert seeded["access_token"] == "fresh-at"
assert seeded["last_status"] is None
assert seeded["last_error_code"] is None
# Independent acctB: NOT refreshed AND error markers NOT cleared.
# (Its 429 quota state belongs to acctB's own account, not acctA's re-auth.)
acctB = next(e for e in pool if e["id"] == "acctB")
assert acctB["access_token"] == "acctB-at" # not overwritten
assert acctB["last_status"] == "exhausted" # not cleared
assert acctB["last_error_code"] == 429
assert acctB["last_error_reason"] == "quota_exhausted"
def test_import_codex_cli_tokens(tmp_path, monkeypatch):
codex_home = tmp_path / "codex-cli"
codex_home.mkdir(parents=True, exist_ok=True)

View file

@ -397,15 +397,92 @@ def test_auth_add_codex_oauth_persists_pool_entry(tmp_path, monkeypatch):
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
entries = payload["credential_pool"]["openai-codex"]
entry = next(item for item in entries if item["source"] == "device_code")
# The add path now creates a distinct, self-contained ``manual:device_code``
# pool entry per account instead of routing through the singleton save path
# (which collapsed multiple accounts into the latest login — #39236).
entry = next(item for item in entries if item["source"] == "manual:device_code")
assert payload["active_provider"] == "openai-codex"
assert payload["providers"]["openai-codex"]["tokens"]["access_token"] == token
# No singleton ``providers.openai-codex`` block is written by the add path.
assert "openai-codex" not in payload.get("providers", {})
assert entry["label"] == "codex@example.com"
assert entry["source"] == "device_code"
assert entry["source"] == "manual:device_code"
assert entry["access_token"] == token
assert entry["refresh_token"] == "refresh-token"
assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
def test_auth_add_codex_oauth_keeps_distinct_pool_accounts(tmp_path, monkeypatch):
"""Two ``hermes auth add openai-codex`` runs for different ChatGPT
accounts must produce two independent pool entries with distinct tokens.
Regression for #39236: the add path used to route through the singleton
``_save_codex_tokens`` save, so the second login overwrote the first
account's singleton-mirrored ``device_code`` entry instead of adding a
second independent one. ``hermes auth list`` showed two labels sharing
one token pair, and rotation silently always used the latest account.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
first_token = _jwt_with_email("first-codex@example.com")
second_token = _jwt_with_email("second-codex@example.com")
logins = iter(
[
{
"tokens": {
"access_token": first_token,
"refresh_token": "first-refresh-token",
},
"base_url": "https://chatgpt.com/backend-api/codex",
"last_refresh": "2026-03-23T10:00:00Z",
},
{
"tokens": {
"access_token": second_token,
"refresh_token": "second-refresh-token",
},
"base_url": "https://chatgpt.com/backend-api/codex",
"last_refresh": "2026-03-23T10:05:00Z",
},
]
)
monkeypatch.setattr("hermes_cli.auth._codex_device_code_login", lambda: next(logins))
from hermes_cli.auth_commands import auth_add_command
from agent.credential_pool import load_pool
class _Args:
provider = "openai-codex"
auth_type = "oauth"
api_key = None
label = None
auth_add_command(_Args())
auth_add_command(_Args())
pool = load_pool("openai-codex")
entries = pool.entries()
assert [entry.source for entry in entries] == [
"manual:device_code",
"manual:device_code",
]
assert [entry.label for entry in entries] == [
"first-codex@example.com",
"second-codex@example.com",
]
assert [entry.access_token for entry in entries] == [first_token, second_token]
assert [entry.refresh_token for entry in entries] == [
"first-refresh-token",
"second-refresh-token",
]
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
# No singleton block — the add path is now pool-only.
assert "openai-codex" not in payload.get("providers", {})
# First add activated the provider; second add left it as-is.
assert payload["active_provider"] == "openai-codex"
def test_auth_add_xai_oauth_sets_active_provider(tmp_path, monkeypatch):
"""hermes auth add xai-oauth must write providers singleton and set active_provider.
@ -1313,9 +1390,9 @@ def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
payload = json.loads((hermes_home / "auth.json").read_text())
# Suppression marker must be cleared
assert "openai-codex" not in payload.get("suppressed_sources", {})
# New pool entry must be present
# New pool entry must be present (distinct manual:device_code entry — #39236)
entries = payload["credential_pool"]["openai-codex"]
assert any(e["source"] == "device_code" for e in entries)
assert any(e["source"] == "manual:device_code" for e in entries)
assert payload["active_provider"] == "openai-codex"

View file

@ -519,3 +519,78 @@ def test_gui_does_not_retry_when_purge_finds_nothing(tmp_path, monkeypatch, caps
mock_purge.assert_called_once()
assert mock_run.call_count == 1
assert "Desktop GUI build failed" in capsys.readouterr().out
class _FakeProc:
"""Minimal psutil.Process stand-in for the lock-breaker tests."""
def __init__(self, pid: int, exe: str | None):
self.pid = pid
self.info = {"pid": pid, "exe": exe}
self.terminated = False
self.killed = False
def terminate(self):
self.terminated = True
def kill(self):
self.killed = True
def test_stop_desktop_build_lock_noop_off_windows(tmp_path, monkeypatch):
"""POSIX can unlink a running binary, so the helper is a no-op there."""
desktop_dir = tmp_path / "apps" / "desktop"
exe = desktop_dir / "release" / "linux-unpacked" / "hermes"
exe.parent.mkdir(parents=True)
exe.write_text("", encoding="utf-8")
monkeypatch.setattr(cli_main.sys, "platform", "linux")
proc = _FakeProc(4321, str(exe))
with patch("psutil.process_iter", return_value=[proc]) as it:
assert cli_main._stop_desktop_processes_locking_build(desktop_dir) == []
it.assert_not_called()
assert proc.terminated is False
def test_stop_desktop_build_lock_terminates_only_release_procs(tmp_path, monkeypatch):
desktop_dir = tmp_path / "apps" / "desktop"
release = desktop_dir / "release" / "win-unpacked"
release.mkdir(parents=True)
locker_exe = release / "Hermes.exe"
locker_exe.write_text("", encoding="utf-8")
other_exe = tmp_path / "elsewhere" / "Hermes.exe"
other_exe.parent.mkdir(parents=True)
other_exe.write_text("", encoding="utf-8")
monkeypatch.setattr(cli_main.sys, "platform", "win32")
monkeypatch.setattr(cli_main.os, "getpid", lambda: 999)
locker = _FakeProc(101, str(locker_exe))
unrelated = _FakeProc(102, str(other_exe))
selfish = _FakeProc(999, str(locker_exe)) # our own PID — never killed
no_exe = _FakeProc(103, None)
captured = {}
def _wait(procs, timeout=None):
captured["waited"] = list(procs)
return procs, []
with patch("psutil.process_iter", return_value=[locker, unrelated, selfish, no_exe]), \
patch("psutil.wait_procs", side_effect=_wait):
stopped = cli_main._stop_desktop_processes_locking_build(desktop_dir)
assert stopped == [101]
assert locker.terminated is True
assert unrelated.terminated is False
assert selfish.terminated is False
assert captured["waited"] == [locker]
def test_stop_desktop_build_lock_no_release_dir(tmp_path, monkeypatch):
desktop_dir = tmp_path / "apps" / "desktop"
desktop_dir.mkdir(parents=True)
monkeypatch.setattr(cli_main.sys, "platform", "win32")
with patch("psutil.process_iter") as it:
assert cli_main._stop_desktop_processes_locking_build(desktop_dir) == []
it.assert_not_called()

View file

@ -350,7 +350,7 @@ def test_cmd_update_retries_optional_extras_individually_when_all_fails(monkeypa
def fake_run(cmd, **kwargs):
recorded.append(cmd)
if cmd == ["git", "fetch", "origin"]:
if cmd == ["git", "fetch", "origin", "main"]:
return SimpleNamespace(stdout="", stderr="", returncode=0)
if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
@ -399,7 +399,7 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path):
def fake_run(cmd, **kwargs):
recorded.append(cmd)
if cmd == ["git", "fetch", "origin"]:
if cmd == ["git", "fetch", "origin", "main"]:
return SimpleNamespace(stdout="", stderr="", returncode=0)
if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
@ -630,6 +630,23 @@ def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path):
assert len(checkout_calls) == 0
def test_cmd_update_fetch_is_scoped_to_target_branch(monkeypatch, tmp_path):
"""The update fetch must name the target branch. A bare `git fetch origin`
pulls every ref, and this repo has thousands of auto-generated branches, so
an unscoped fetch can stall for minutes on a non-single-branch checkout."""
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
side_effect, recorded = _make_update_side_effect()
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
hermes_main.cmd_update(SimpleNamespace())
fetch_calls = [c for c in recorded if "fetch" in c]
assert fetch_calls == [["git", "fetch", "origin", "main"]]
assert ["git", "fetch", "origin"] not in recorded
# ---------------------------------------------------------------------------
# Fetch failure — friendly error messages
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,83 @@
"""Test the platform-branched PTY bridge import in hermes_cli.web_server.
The /api/pty WebSocket handler in web_server.py picks its bridge at import
time via ``sys.platform.startswith("win")`` Windows gets the ConPTY
backend, POSIX gets the fcntl/termios one. Both branches must:
1. Expose ``PtyBridge`` as the bridge class (or None) and
``PtyUnavailableError`` as an exception class.
2. Set ``_PTY_BRIDGE_AVAILABLE`` correctly.
3. Never raise at import time when the platform-native dependency is
missing the dashboard's non-chat tabs must keep loading.
This test asserts the live state on whichever platform CI runs on, plus a
source-text check confirming the branch shape is preserved so a future
refactor can't accidentally collapse it back to a POSIX-only import.
"""
from __future__ import annotations
import sys
import pytest
from hermes_cli import web_server
def test_web_server_exposes_pty_bridge_symbols():
"""The two symbols /api/pty consumes must always exist."""
assert hasattr(web_server, "PtyBridge")
assert hasattr(web_server, "PtyUnavailableError")
assert hasattr(web_server, "_PTY_BRIDGE_AVAILABLE")
# PtyUnavailableError is always an exception class — either the real
# one from the platform bridge, or the local fallback class.
assert isinstance(web_server.PtyUnavailableError, type)
assert issubclass(web_server.PtyUnavailableError, BaseException)
@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows-only")
def test_web_server_uses_win_pty_bridge_on_windows():
"""On native Windows, web_server.PtyBridge must be the ConPTY backend."""
from hermes_cli.win_pty_bridge import WinPtyBridge
assert web_server.PtyBridge is WinPtyBridge
assert web_server._PTY_BRIDGE_AVAILABLE is True
# And the error class must be the one from the same module so isinstance
# checks in /api/pty's spawn fallback path actually work.
from hermes_cli.win_pty_bridge import PtyUnavailableError as WinErr
assert web_server.PtyUnavailableError is WinErr
@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX-only")
def test_web_server_uses_posix_pty_bridge_on_posix():
"""On POSIX, the bridge must be the fcntl/termios PtyBridge."""
from hermes_cli.pty_bridge import PtyBridge as PosixBridge
from hermes_cli.pty_bridge import PtyUnavailableError as PosixErr
assert web_server.PtyBridge is PosixBridge
assert web_server._PTY_BRIDGE_AVAILABLE is True
assert web_server.PtyUnavailableError is PosixErr
def test_pty_bridge_import_block_is_platform_branched():
"""Source-level guard: a future refactor must not collapse the branch
back to a single POSIX import. Reads web_server.py directly so this
fails the same way on every OS the runtime symbol checks above can
pass even when the branch shape is wrong on the current platform."""
src = pytest.importorskip("inspect").getsource(web_server)
# The shape we expect (from PR #39913):
#
# if sys.platform.startswith("win"):
# try:
# from hermes_cli.win_pty_bridge import WinPtyBridge as PtyBridge, ...
# except ImportError:
# PtyBridge = None
# ...
# else:
# try:
# from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError
# ...
assert 'sys.platform.startswith("win")' in src or "sys.platform.startswith('win')" in src
assert "from hermes_cli.win_pty_bridge import" in src
assert "from hermes_cli.pty_bridge import" in src

View file

@ -0,0 +1,315 @@
"""Unit tests for hermes_cli.win_pty_bridge — ConPTY spawning + byte forwarding.
Windows-only counterpart to tests/hermes_cli/test_pty_bridge.py. Drives
``WinPtyBridge`` with minimal Windows processes (``cmd.exe``, ``python -c ``)
to verify it behaves like a PTY you can read/write/resize/close, then a small
set of platform-fallback assertions (``is_available``, ``PtyUnavailableError``)
that run on every OS so the import surface stays exercised in CI.
The bridge is the ConPTY backend behind the dashboard ``/chat`` tab see
``hermes_cli/web_server.py`` ``/api/pty`` handler so these tests are the
unit-level half of the integration check that the dashboard chat pane is
actually live on native Windows.
"""
from __future__ import annotations
import os
import sys
import time
import pytest
# WinPtyBridge can be imported on every platform — ``is_available`` just
# returns False when pywinpty isn't usable. Importing the module itself
# must never raise, otherwise the web_server import branch becomes a trap.
from hermes_cli.win_pty_bridge import PtyUnavailableError, WinPtyBridge
windows_only = pytest.mark.skipif(
not sys.platform.startswith("win"),
reason="ConPTY bridge is Windows-only",
)
def _read_until(bridge: WinPtyBridge, needle: bytes, timeout: float = 10.0) -> bytes:
"""Accumulate PTY output until we see ``needle`` or time out.
Mirrors the helper in test_pty_bridge.py so failures look familiar.
"""
deadline = time.monotonic() + timeout
buf = bytearray()
while time.monotonic() < deadline:
chunk = bridge.read(timeout=0.2)
if chunk is None:
break
buf.extend(chunk)
if needle in buf:
return bytes(buf)
return bytes(buf)
# ---------------------------------------------------------------------------
# Cross-platform fallback semantics
# ---------------------------------------------------------------------------
class TestWinPtyBridgeUnavailable:
"""Module-level surface that must stay importable on every OS so the
web_server platform branch doesn't blow up at import time when pywinpty
is missing or the host isn't Windows."""
def test_error_is_importable_and_carries_message(self):
err = PtyUnavailableError("conpty missing")
assert "conpty" in str(err)
def test_bridge_class_is_importable(self):
# The platform-branched import in web_server.py relies on this:
# from hermes_cli.win_pty_bridge import WinPtyBridge, PtyUnavailableError
# Both symbols must always exist; ``is_available()`` is the gate.
assert WinPtyBridge is not None
assert callable(WinPtyBridge.is_available)
@pytest.mark.skipif(sys.platform.startswith("win"), reason="non-Windows only")
def test_spawn_raises_unavailable_off_windows(self):
with pytest.raises(PtyUnavailableError):
WinPtyBridge.spawn(["true"])
# ---------------------------------------------------------------------------
# Windows-only end-to-end behaviour
# ---------------------------------------------------------------------------
@windows_only
class TestWinPtyBridgeSpawn:
def test_is_available_on_windows(self):
assert WinPtyBridge.is_available() is True
def test_spawn_returns_bridge_with_pid(self):
bridge = WinPtyBridge.spawn(["cmd.exe", "/c", "exit 0"])
try:
assert bridge.pid > 0
finally:
bridge.close()
def test_spawn_raises_on_missing_argv0(self, tmp_path):
# pywinpty wraps CreateProcessW failures; surface as OSError / RuntimeError.
bogus = str(tmp_path / "definitely-not-a-real-binary.exe")
with pytest.raises((FileNotFoundError, OSError, RuntimeError, PtyUnavailableError)):
WinPtyBridge.spawn([bogus])
@windows_only
class TestWinPtyBridgeIO:
def test_reads_child_stdout(self):
bridge = WinPtyBridge.spawn(["cmd.exe", "/c", "echo hermes-ok"])
try:
output = _read_until(bridge, b"hermes-ok")
assert b"hermes-ok" in output
finally:
bridge.close()
def test_write_sends_to_child_stdin(self):
# python -c reads stdin, echoes a marker, exits. More reliable than
# ``cat`` (not on Windows) and doesn't depend on a particular shell.
script = (
"import sys; "
"line = sys.stdin.readline().strip(); "
"sys.stdout.write('GOT:' + line + '\\n'); "
"sys.stdout.flush()"
)
bridge = WinPtyBridge.spawn([sys.executable, "-c", script])
try:
bridge.write(b"hello-pty\r\n")
output = _read_until(bridge, b"GOT:hello-pty")
assert b"GOT:hello-pty" in output
finally:
bridge.close()
def test_write_after_close_is_silent(self):
bridge = WinPtyBridge.spawn(["cmd.exe", "/c", "exit 0"])
bridge.close()
# Must not raise — the dashboard WebSocket reader sometimes writes
# a final keystroke after the user has already closed the tab.
bridge.write(b"ignored")
def test_read_returns_none_after_child_exits(self):
bridge = WinPtyBridge.spawn(["cmd.exe", "/c", "echo done"])
try:
_read_until(bridge, b"done")
# Give the child a beat to exit, then drain until EOF.
deadline = time.monotonic() + 5.0
while bridge.is_alive() and time.monotonic() < deadline:
bridge.read(timeout=0.1)
got_none = False
for _ in range(20):
if bridge.read(timeout=0.1) is None:
got_none = True
break
assert got_none, "WinPtyBridge.read did not return None after child EOF"
finally:
bridge.close()
@windows_only
class TestWinPtyBridgeResize:
def test_resize_does_not_raise_on_live_child(self):
# ConPTY exposes no ioctl-equivalent for reading the child's current
# winsize from Python land, so we can't verify the new dimensions
# the way the POSIX test does (which reads TIOCGWINSZ). What we
# CAN guarantee is what the dashboard depends on: ``resize`` never
# raises, the bridge stays alive, and subsequent I/O still works.
bridge = WinPtyBridge.spawn(
[sys.executable, "-c", "import time; time.sleep(1.0)"],
cols=80,
rows=24,
)
try:
bridge.resize(cols=123, rows=45)
assert bridge.is_alive()
finally:
bridge.close()
def test_resize_clamps_garbage_dimensions(self):
# Mirror the POSIX clamp test: a broken winsize probe must never
# propagate to the ConPTY API. 131072 > unsigned short max — the
# bridge has to coerce it down without raising.
bridge = WinPtyBridge.spawn(
[sys.executable, "-c", "import time; time.sleep(1.0)"],
cols=80,
rows=24,
)
try:
bridge.resize(cols=131072, rows=1) # must not raise
bridge.resize(cols=0, rows=-5) # nor this
assert bridge.is_alive()
finally:
bridge.close()
def test_resize_after_close_is_silent(self):
bridge = WinPtyBridge.spawn(["cmd.exe", "/c", "exit 0"])
bridge.close()
# Must not raise — closed bridges still receive late resize escapes
# from xterm.js when the browser tab is closed mid-stream.
bridge.resize(cols=100, rows=40)
@windows_only
class TestClampDimension:
"""The clamp helper is the load-bearing piece — the dashboard sends
untrusted winsize values straight from xterm.js, and pywinpty's
setwinsize will happily raise on out-of-range u16 values."""
def test_clamps_above_max(self):
from hermes_cli.win_pty_bridge import _MAX_COLS, _MAX_ROWS, _clamp
assert _clamp(131072, _MAX_COLS) == _MAX_COLS
assert _clamp(131072, _MAX_ROWS) == _MAX_ROWS
def test_floors_at_one(self):
from hermes_cli.win_pty_bridge import _MAX_COLS, _clamp
assert _clamp(0, _MAX_COLS) == 1
assert _clamp(-5, _MAX_COLS) == 1
def test_passes_through_sane_values(self):
from hermes_cli.win_pty_bridge import _MAX_COLS, _clamp
assert _clamp(80, _MAX_COLS) == 80
assert _clamp(2000, _MAX_COLS) == 2000
def test_non_numeric_falls_back_to_min(self):
from hermes_cli.win_pty_bridge import _MAX_COLS, _clamp
assert _clamp(None, _MAX_COLS) == 1 # type: ignore[arg-type]
assert _clamp("not-a-number", _MAX_COLS) == 1 # type: ignore[arg-type]
assert _clamp(float("nan"), _MAX_COLS) == 1 # type: ignore[arg-type]
assert _clamp(float("inf"), _MAX_COLS) == 1 # type: ignore[arg-type]
@windows_only
class TestWinPtyBridgeClose:
def test_close_is_idempotent(self):
bridge = WinPtyBridge.spawn(
[sys.executable, "-c", "import time; time.sleep(30)"]
)
bridge.close()
bridge.close() # must not raise
assert not bridge.is_alive()
def test_close_terminates_long_running_child(self):
bridge = WinPtyBridge.spawn(
[sys.executable, "-c", "import time; time.sleep(30)"]
)
pid = bridge.pid
assert bridge.is_alive(), f"child pid {pid} not alive before close"
bridge.close()
# The bridge itself reports liveness via pywinpty.isalive(), which is
# the same probe the dashboard PTY reader uses to decide when to stop
# forwarding bytes — verifying that flips to False is the contract
# that matters for /api/pty.
deadline = time.monotonic() + 5.0
while bridge.is_alive() and time.monotonic() < deadline:
time.sleep(0.1)
assert not bridge.is_alive(), (
f"WinPtyBridge.is_alive() still True after close(); pid {pid}"
)
@windows_only
class TestWinPtyBridgeEnv:
def test_cwd_is_respected(self, tmp_path):
bridge = WinPtyBridge.spawn(
[sys.executable, "-c", "import os; print(os.getcwd())"],
cwd=str(tmp_path),
)
try:
# Path is case-insensitive on Windows; compare lowercased.
needle_resolved = str(tmp_path.resolve()).lower().encode()
deadline = time.monotonic() + 5.0
buf = bytearray()
while time.monotonic() < deadline:
chunk = bridge.read(timeout=0.2)
if chunk is None:
break
buf.extend(chunk)
if needle_resolved in bytes(buf).lower():
break
assert needle_resolved in bytes(buf).lower(), (
f"cwd {tmp_path!s} not echoed by child; got {bytes(buf)!r}"
)
finally:
bridge.close()
def test_env_is_forwarded(self):
bridge = WinPtyBridge.spawn(
[
sys.executable,
"-c",
"import os; print('HERMES_PTY_TEST=' + os.environ.get('HERMES_PTY_TEST',''))",
],
env={**os.environ, "HERMES_PTY_TEST": "pty-env-works"},
)
try:
output = _read_until(bridge, b"pty-env-works")
assert b"pty-env-works" in output
finally:
bridge.close()
def test_spawn_defaults_term_when_not_set(self):
# The bridge should set TERM=xterm-256color when the caller's env
# doesn't already carry one — xterm.js expects ANSI/SGR sequences.
env = {k: v for k, v in os.environ.items() if k.upper() != "TERM"}
bridge = WinPtyBridge.spawn(
[
sys.executable,
"-c",
"import os; print('TERM=' + os.environ.get('TERM',''))",
],
env=env,
)
try:
output = _read_until(bridge, b"TERM=")
assert b"TERM=xterm-256color" in output
finally:
bridge.close()

View file

@ -0,0 +1,283 @@
"""Tests for the Photon auth module (device login + project + user creation)."""
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any, Dict
import pytest
from plugins.platforms.photon import auth as photon_auth
# ---------------------------------------------------------------------------
# Fake httpx — we don't want to hit the real Photon API in unit tests.
class _FakeResponse:
def __init__(
self,
*,
status: int = 200,
json_body: Any = None,
headers: Dict[str, str] | None = None,
text: str = "",
) -> None:
self.status_code = status
self._json = json_body if json_body is not None else {}
self.headers = headers or {}
self.text = text
def json(self) -> Any:
return self._json
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
@pytest.fixture
def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# The auth module memoises by reading get_hermes_home at call time
# so the env var is what matters.
return home
def test_store_and_load_photon_token(tmp_hermes_home: Path) -> None:
photon_auth.store_photon_token("abc123def456")
assert photon_auth.load_photon_token() == "abc123def456"
auth_json = json.loads((tmp_hermes_home / "auth.json").read_text())
assert "credential_pool" in auth_json
assert auth_json["credential_pool"]["photon"][0]["access_token"] == "abc123def456"
def test_store_and_load_project_credentials(tmp_hermes_home: Path) -> None:
photon_auth.store_project_credentials(
"proj-uuid", "secret-key", name="Test Project",
)
pid, secret = photon_auth.load_project_credentials()
assert pid == "proj-uuid"
assert secret == "secret-key"
def test_load_project_credentials_env_override(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
photon_auth.store_project_credentials("from-file", "secret-file")
monkeypatch.setenv("PHOTON_PROJECT_ID", "from-env")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret-env")
pid, secret = photon_auth.load_project_credentials()
assert pid == "from-env"
assert secret == "secret-env"
def test_request_device_code(monkeypatch: pytest.MonkeyPatch) -> None:
captured: Dict[str, Any] = {}
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
captured["url"] = url
captured["body"] = json
return _FakeResponse(json_body={
"device_code": "dev-code-xyz",
"user_code": "ABCD-1234",
"verification_uri": "https://app.photon.codes/device",
"verification_uri_complete": "https://app.photon.codes/device?code=ABCD-1234",
"expires_in": 600,
"interval": 5,
})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
code = photon_auth.request_device_code()
assert code.device_code == "dev-code-xyz"
assert code.user_code == "ABCD-1234"
assert code.expires_in == 600
assert "/api/auth/device/code" in captured["url"]
assert captured["body"]["client_id"] == "hermes-agent"
def test_poll_for_token_via_header(monkeypatch: pytest.MonkeyPatch) -> None:
"""Token from set-auth-token header is the documented mechanism."""
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
return _FakeResponse(
status=200,
json_body={"session": {}, "user": {}},
headers={"set-auth-token": "bearer-xyz"},
)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
code = photon_auth.DeviceCode(
device_code="d", user_code="u",
verification_uri="https://x", verification_uri_complete=None,
expires_in=10, interval=0,
)
token = photon_auth.poll_for_token(code, interval=0, timeout=2)
assert token == "bearer-xyz"
def test_poll_for_token_via_body_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
"""If the header is absent we fall back to session.access_token."""
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
return _FakeResponse(
status=200,
json_body={"session": {"access_token": "from-body"}, "user": {}},
)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
code = photon_auth.DeviceCode(
device_code="d", user_code="u",
verification_uri="https://x", verification_uri_complete=None,
expires_in=10, interval=0,
)
assert photon_auth.poll_for_token(code, interval=0, timeout=2) == "from-body"
def test_poll_for_token_propagates_access_denied(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
return _FakeResponse(
status=400, json_body={"error": "access_denied"},
)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
code = photon_auth.DeviceCode(
device_code="d", user_code="u",
verification_uri="https://x", verification_uri_complete=None,
expires_in=10, interval=0,
)
with pytest.raises(RuntimeError, match="access_denied"):
photon_auth.poll_for_token(code, interval=0, timeout=2)
def test_create_user_rejects_invalid_phone() -> None:
with pytest.raises(ValueError, match="E.164"):
photon_auth.create_user(
"proj", "secret", phone_number="not-a-number",
)
def test_create_user_posts_shared_type(monkeypatch: pytest.MonkeyPatch) -> None:
captured: Dict[str, Any] = {}
def fake_post(url: str, *, json: Dict[str, Any], auth: tuple, timeout: float) -> _FakeResponse:
captured["url"] = url
captured["body"] = json
captured["auth"] = auth
return _FakeResponse(json_body={
"succeed": True,
"data": {
"id": "user-uuid",
"phoneNumber": "+15551234567",
"assignedPhoneNumber": "+15559999999",
},
})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
user = photon_auth.create_user(
"proj-id", "proj-secret",
phone_number="+15551234567",
)
assert user["assignedPhoneNumber"] == "+15559999999"
assert captured["auth"] == ("proj-id", "proj-secret")
assert captured["body"]["type"] == "shared"
assert captured["body"]["phoneNumber"] == "+15551234567"
assert "/projects/proj-id/users/" in captured["url"]
def test_register_webhook_surfaces_secret(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_post(url: str, *, json: Dict[str, Any], auth: tuple, timeout: float) -> _FakeResponse:
return _FakeResponse(json_body={
"succeed": True,
"data": {
"id": "wh-uuid",
"webhookUrl": json["webhookUrl"],
"signingSecret": "0" * 64,
},
})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
data = photon_auth.register_webhook(
"proj", "secret", webhook_url="https://x.example.com/hook",
)
assert data["signingSecret"] == "0" * 64
assert data["webhookUrl"] == "https://x.example.com/hook"
def test_persist_webhook_signing_secret_writes_env(
tmp_hermes_home: Path,
) -> None:
"""The helper hands the secret to save_env_value, never returns it."""
summary: list = []
response = {
"id": "wh-uuid",
"webhookUrl": "https://x.example.com/hook",
"signingSecret": "ABCDEF1234567890" * 4,
}
ok = photon_auth.persist_webhook_signing_secret(
response, on_summary=summary.append,
)
assert ok is True
env_path = tmp_hermes_home / ".env"
assert env_path.exists()
env_text = env_path.read_text()
assert "PHOTON_WEBHOOK_SECRET=ABCDEF1234567890" in env_text
# The on_summary callback gets the redacted response + a saved-to path;
# none of those strings should leak the raw secret.
joined = "\n".join(summary)
assert "<redacted>" in joined
assert "ABCDEF1234567890" not in joined
def test_persist_webhook_signing_secret_no_secret_no_write(
tmp_hermes_home: Path,
) -> None:
summary: list = []
ok = photon_auth.persist_webhook_signing_secret(
{"id": "wh-uuid", "webhookUrl": "https://x"},
on_summary=summary.append,
)
assert ok is False
# No env file written; summary callback still received the redacted
# response (without a signingSecret key, nothing to redact).
assert not (tmp_hermes_home / ".env").exists()
def test_credential_summary_returns_only_display_strings(
tmp_hermes_home: Path,
) -> None:
"""credential_summary must not leak raw token/secret material."""
photon_auth.store_photon_token("token-aaaaaaaaaaaaaaaa")
photon_auth.store_project_credentials("proj-uuid", "secret-bbbbbbbbbbb")
summary = photon_auth.credential_summary()
blob = "\n".join(summary.values())
assert "token-aaaa" not in blob
assert "secret-bbbb" not in blob
assert summary["device_token"].startswith("")
assert summary["project_key"].startswith("")
assert summary["project_id"] == "proj-uuid"
def test_print_credential_summary_emits_only_display_strings(
tmp_hermes_home: Path,
) -> None:
"""The emit callback must never receive raw credential bytes."""
photon_auth.store_photon_token("token-aaaaaaaaaaaaaaaa")
photon_auth.store_project_credentials("proj-uuid", "secret-bbbbbbbbbbb")
lines: list = []
photon_auth.print_credential_summary(lines.append)
blob = "\n".join(lines)
assert "token-aaaa" not in blob
assert "secret-bbbb" not in blob
assert "✓ stored" in blob # device token line
assert "proj-uuid" in blob # project id is intentionally surfaced
# Header is always emitted
assert any("Photon iMessage status" in line for line in lines)

View file

@ -0,0 +1,139 @@
"""Inbound dispatch + dedup tests for PhotonAdapter.
These tests bypass the aiohttp server they call ``_dispatch_inbound``
and ``_is_duplicate`` directly. That keeps them fast and means we can
exercise the message-shape parsing logic without binding ports.
"""
from __future__ import annotations
from typing import List
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
# Avoid touching real auth.json / env.
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False)
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
@pytest.mark.asyncio
async def test_dispatch_text_dm(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
payload = {
"event": "messages",
"space": {"id": "any;-;+15551234567", "platform": "iMessage"},
"message": {
"id": "spc-msg-abc",
"platform": "iMessage",
"direction": "inbound",
"timestamp": "2026-05-14T19:06:32.000Z",
"sender": {"id": "+15551234567", "platform": "iMessage"},
"space": {"id": "any;-;+15551234567", "platform": "iMessage"},
"content": {"type": "text", "text": "hello world"},
},
}
await adapter._dispatch_inbound(payload)
assert len(captured) == 1
event = captured[0]
assert event.text == "hello world"
assert event.message_type == MessageType.TEXT
assert event.message_id == "spc-msg-abc"
src = event.source
assert src is not None
assert src.platform == Platform("photon")
assert src.chat_id == "any;-;+15551234567"
assert src.chat_type == "dm"
assert src.user_id == "+15551234567"
@pytest.mark.asyncio
async def test_dispatch_group_id_detected(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
payload = {
"event": "messages",
"space": {"id": "any;+;group-guid-xyz", "platform": "iMessage"},
"message": {
"id": "spc-msg-grp",
"timestamp": "2026-05-14T19:06:32.000Z",
"sender": {"id": "+15551234567"},
"space": {"id": "any;+;group-guid-xyz"},
"content": {"type": "text", "text": "hi group"},
},
}
await adapter._dispatch_inbound(payload)
assert captured[0].source.chat_type == "group"
@pytest.mark.asyncio
async def test_dispatch_attachment_surfaces_marker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
payload = {
"event": "messages",
"message": {
"id": "spc-msg-att",
"timestamp": "2026-05-14T19:06:32.000Z",
"sender": {"id": "+15551234567"},
"space": {"id": "any;-;+15551234567"},
"content": {
"type": "attachment",
"name": "IMG_4127.HEIC",
"mimeType": "image/heic",
"size": 12345,
},
},
}
await adapter._dispatch_inbound(payload)
assert len(captured) == 1
event = captured[0]
# Attachment carries metadata marker; mime → MessageType.PHOTO.
assert "Photon attachment received" in event.text
assert "IMG_4127.HEIC" in event.text
assert event.message_type == MessageType.PHOTO
def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
assert adapter._is_duplicate("id-1") is False
assert adapter._is_duplicate("id-1") is True
assert adapter._is_duplicate("id-2") is False
assert adapter._is_duplicate("id-1") is True # still dup
def test_check_requirements_without_node(monkeypatch: pytest.MonkeyPatch) -> None:
# If no node binary on PATH the adapter should refuse to start.
from plugins.platforms.photon import adapter as adapter_mod
monkeypatch.setattr(adapter_mod.shutil, "which", lambda _name: None)
assert adapter_mod.check_requirements() is False

View file

@ -0,0 +1,146 @@
"""Group-chat mention-gating tests for PhotonAdapter.
Parity with the BlueBubbles iMessage channel: when ``require_mention`` is
enabled, group messages are dropped unless they hit a wake-word pattern,
and the leading wake word is stripped from the ones that pass. DMs are
never gated.
These call ``_dispatch_inbound`` directly (no aiohttp / ports) and assert
on what reaches ``handle_message``.
"""
from __future__ import annotations
from typing import List
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageEvent
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch, extra: dict | None = None) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False)
monkeypatch.delenv("PHOTON_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("PHOTON_MENTION_PATTERNS", raising=False)
cfg = PlatformConfig(enabled=True, token="", extra=extra or {})
return PhotonAdapter(cfg)
def _group_payload(text: str) -> dict:
return {
"event": "messages",
"message": {
"id": f"grp-{abs(hash(text))}",
"timestamp": "2026-05-14T19:06:32.000Z",
"sender": {"id": "+15551234567"},
"space": {"id": "any;+;group-guid-xyz"},
"content": {"type": "text", "text": text},
},
}
def _dm_payload(text: str) -> dict:
return {
"event": "messages",
"message": {
"id": f"dm-{abs(hash(text))}",
"timestamp": "2026-05-14T19:06:32.000Z",
"sender": {"id": "+15551234567"},
"space": {"id": "any;-;+15551234567"},
"content": {"type": "text", "text": text},
},
}
def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
return captured
def test_require_mention_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
assert adapter.require_mention is False
# Defaults compile to the two Hermes wake-word patterns.
assert len(adapter._mention_patterns) == 2
@pytest.mark.asyncio
async def test_group_message_dropped_without_mention(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_group_payload("just chatting, no wake word"))
assert captured == []
@pytest.mark.asyncio
async def test_group_message_passes_and_strips_wake_word(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_group_payload("Hermes what's the weather"))
assert len(captured) == 1
# Leading wake word stripped before dispatch.
assert captured[0].text == "what's the weather"
@pytest.mark.asyncio
async def test_dm_never_gated(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_dm_payload("no wake word here"))
assert len(captured) == 1
assert captured[0].text == "no wake word here"
@pytest.mark.asyncio
async def test_require_mention_off_passes_group_messages(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch) # require_mention defaults off
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_group_payload("plain group chatter"))
assert len(captured) == 1
assert captured[0].text == "plain group chatter"
def test_custom_mention_patterns_from_config(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(
monkeypatch,
extra={"require_mention": True, "mention_patterns": [r"(?<![\w@])@?amos\b[,:\-]?"]},
)
assert adapter.require_mention is True
assert len(adapter._mention_patterns) == 1
assert adapter._message_matches_mention_patterns("amos help me") is True
assert adapter._message_matches_mention_patterns("hermes help me") is False
def test_mention_patterns_env_comma_separated(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False)
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
monkeypatch.setenv("PHOTON_MENTION_PATTERNS", r"bot\b, assistant\b")
cfg = PlatformConfig(enabled=True, token="", extra={})
adapter = PhotonAdapter(cfg)
assert adapter.require_mention is True
assert len(adapter._mention_patterns) == 2
assert adapter._message_matches_mention_patterns("hey bot") is True
def test_invalid_pattern_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(
monkeypatch,
extra={"require_mention": True, "mention_patterns": ["(unclosed", r"good\b"]},
)
# Bad regex dropped, good one kept.
assert len(adapter._mention_patterns) == 1
assert adapter._message_matches_mention_patterns("a good thing") is True

View file

@ -0,0 +1,95 @@
"""Signature verification tests for the Photon webhook receiver."""
from __future__ import annotations
import hashlib
import hmac
import time
import pytest
from plugins.platforms.photon.adapter import verify_signature
def _sign(secret: str, body: bytes, ts: int) -> str:
return "v0=" + hmac.new(
secret.encode(), f"v0:{ts}:".encode() + body, hashlib.sha256,
).hexdigest()
def test_accepts_valid_signature() -> None:
secret = "topsecret-32chars-or-whatever"
body = b'{"event":"messages"}'
ts = int(time.time())
sig = _sign(secret, body, ts)
assert verify_signature(
body=body, timestamp_header=str(ts), signature_header=sig,
signing_secret=secret,
)
def test_rejects_tampered_body() -> None:
secret = "s"
body = b'{"event":"messages"}'
ts = int(time.time())
sig = _sign(secret, body, ts)
assert not verify_signature(
body=body + b" tamper", timestamp_header=str(ts),
signature_header=sig, signing_secret=secret,
)
def test_rejects_wrong_secret() -> None:
body = b"x"
ts = int(time.time())
sig = _sign("right", body, ts)
assert not verify_signature(
body=body, timestamp_header=str(ts), signature_header=sig,
signing_secret="wrong",
)
def test_rejects_drifted_timestamp() -> None:
secret = "s"
body = b"x"
ts = int(time.time()) - 3600 # 1h old; drift window is 5 min
sig = _sign(secret, body, ts)
assert not verify_signature(
body=body, timestamp_header=str(ts), signature_header=sig,
signing_secret=secret,
)
def test_rejects_missing_v0_prefix() -> None:
secret = "s"
body = b"x"
ts = int(time.time())
raw_hex = hmac.new(
secret.encode(), f"v0:{ts}:".encode() + body, hashlib.sha256,
).hexdigest()
# Strip the "v0=" prefix — verify_signature must reject.
assert not verify_signature(
body=body, timestamp_header=str(ts), signature_header=raw_hex,
signing_secret=secret,
)
def test_rejects_empty_inputs() -> None:
assert not verify_signature(
body=b"x", timestamp_header="", signature_header="v0=abc",
signing_secret="s",
)
assert not verify_signature(
body=b"x", timestamp_header="123", signature_header="",
signing_secret="s",
)
assert not verify_signature(
body=b"x", timestamp_header="123", signature_header="v0=abc",
signing_secret="",
)
def test_rejects_non_integer_timestamp() -> None:
assert not verify_signature(
body=b"x", timestamp_header="not-an-int",
signature_header="v0=abc", signing_secret="s",
)

View file

@ -2,10 +2,13 @@
from __future__ import annotations
import asyncio
import builtins
import gc
import importlib
import json
import sys
import warnings
from pathlib import Path
from types import SimpleNamespace
@ -37,7 +40,7 @@ class _FakeNemoRelay:
call_end=self._tool_call_end,
execute=self._tool_execute,
)
self.plugin = SimpleNamespace(initialize=self._plugin_initialize)
self.plugin = SimpleNamespace(initialize=self._plugin_initialize, clear=self._plugin_clear)
self.LLMRequest = _FakeLLMRequest
self.AtofExporterConfig = _FakeAtofExporterConfig
self.AtofExporterMode = SimpleNamespace(Append="append", Overwrite="overwrite")
@ -93,6 +96,9 @@ class _FakeNemoRelay:
self.events.append(("plugin.initialize", config))
return {"diagnostics": []}
async def _plugin_clear(self):
self.events.append(("plugin.clear",))
class _FakeLLMRequest:
def __init__(self, headers, content):
@ -115,6 +121,10 @@ class _FakeAtofExporter:
def register(self, name):
self.events.append(("atof.register", name, self.config.output_directory, self.config.filename))
def deregister(self, name):
self.events.append(("atof.deregister", name, self.config.output_directory, self.config.filename))
return True
class _FakeAtifExporter:
def __init__(self, events, session_id, agent_name, agent_version, kwargs):
@ -445,6 +455,252 @@ output_directory = "{atif_dir}"
assert atif_dir.is_dir()
def test_nemo_relay_plugin_clears_plugins_toml_on_final_session_finalize_and_reinitializes(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
plugin = _fresh_plugin(monkeypatch, fake)
plugins_toml = tmp_path / "plugins.toml"
plugins_toml.write_text(
"""
version = 1
[[components]]
kind = "observability"
enabled = true
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml))
plugin.on_session_start(session_id="s1")
plugin.on_session_finalize(session_id="s1", reason="shutdown")
plugin.on_session_start(session_id="s2")
event_names = [event[0] for event in fake.events]
assert event_names.count("plugin.initialize") == 2
assert event_names.count("plugin.clear") == 1
def test_nemo_relay_plugin_keeps_plugins_toml_active_while_other_sessions_remain(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
plugin = _fresh_plugin(monkeypatch, fake)
plugins_toml = tmp_path / "plugins.toml"
plugins_toml.write_text(
"""
version = 1
[[components]]
kind = "observability"
enabled = true
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml))
plugin.on_session_start(session_id="parent")
plugin.on_session_start(session_id="child")
plugin.on_session_finalize(session_id="child", reason="shutdown")
plugin.on_session_finalize(session_id="parent", reason="shutdown")
event_names = [event[0] for event in fake.events]
assert event_names.count("plugin.initialize") == 1
assert event_names.count("plugin.clear") == 1
def test_nemo_relay_plugin_reinitializes_plugins_toml_inside_active_event_loop(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
plugin = _fresh_plugin(monkeypatch, fake)
plugins_toml = tmp_path / "plugins.toml"
plugins_toml.write_text(
"""
version = 1
[[components]]
kind = "observability"
enabled = true
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml))
async def _drive() -> None:
plugin.on_session_start(session_id="s1")
plugin.on_session_finalize(session_id="s1", reason="shutdown")
plugin.on_session_start(session_id="s2")
await asyncio.sleep(0)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
asyncio.run(_drive())
gc.collect()
assert not any("was never awaited" in str(w.message) for w in caught)
runtime = plugin._get_runtime()
assert runtime is not None
assert runtime._plugin_config_initialized is True
scope_push_names = [event[1] for event in fake.events if event[0] == "scope.push"]
assert "hermes-session-s2" in scope_push_names
def test_nemo_relay_plugin_retries_plugins_toml_after_clear_failure(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
initialize_calls = 0
async def _counting_initialize(config):
nonlocal initialize_calls
initialize_calls += 1
fake.events.append(("plugin.initialize.attempt", initialize_calls, config))
return {"diagnostics": []}
async def _failing_clear():
fake.events.append(("plugin.clear.failed",))
raise RuntimeError("boom")
fake.plugin.initialize = _counting_initialize
fake.plugin.clear = _failing_clear
plugin = _fresh_plugin(monkeypatch, fake)
plugins_toml = tmp_path / "plugins.toml"
plugins_toml.write_text(
"""
version = 1
[[components]]
kind = "observability"
enabled = true
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml))
plugin.on_session_start(session_id="s1")
plugin.on_session_finalize(session_id="s1", reason="shutdown")
plugin.on_session_start(session_id="s2")
event_names = [event[0] for event in fake.events]
assert event_names.count("plugin.initialize.attempt") == 2
assert event_names.count("plugin.clear.failed") == 1
scope_push_names = [event[1] for event in fake.events if event[0] == "scope.push"]
assert "hermes-session-s2" in scope_push_names
def test_nemo_relay_plugin_disables_direct_atif_when_plugins_toml_owns_atif(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
plugin = _fresh_plugin(monkeypatch, fake)
plugins_toml = tmp_path / "plugins.toml"
plugins_toml.write_text(
f"""
version = 1
[[components]]
kind = "observability"
enabled = true
[components.config.atif]
enabled = true
output_directory = "{(tmp_path / "managed-atif").as_posix()}"
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml))
monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1")
monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atif"))
plugin.on_session_start(session_id="s1")
plugin.on_session_finalize(session_id="s1", reason="shutdown")
event_names = [event[0] for event in fake.events]
assert "plugin.initialize" in event_names
assert "plugin.clear" in event_names
assert "atif.register" not in event_names
assert not (tmp_path / "direct-atif" / "hermes-atif-s1.json").exists()
def test_nemo_relay_plugin_keeps_direct_atif_when_plugins_toml_init_fails(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
async def _failing_initialize(config):
fake.events.append(("plugin.initialize.failed", config))
raise RuntimeError("boom")
fake.plugin.initialize = _failing_initialize
plugin = _fresh_plugin(monkeypatch, fake)
plugins_toml = tmp_path / "plugins.toml"
plugins_toml.write_text(
f"""
version = 1
[[components]]
kind = "observability"
enabled = true
[components.config.atif]
enabled = true
output_directory = "{(tmp_path / "managed-atif").as_posix()}"
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml))
monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1")
monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atif"))
plugin.on_session_start(session_id="s1")
plugin.on_session_finalize(session_id="s1", reason="shutdown")
event_names = [event[0] for event in fake.events]
assert "plugin.initialize.failed" in event_names
assert "plugin.clear" not in event_names
assert "atif.register" in event_names
assert (tmp_path / "direct-atif" / "hermes-atif-s1.json").exists()
def test_nemo_relay_plugin_retries_plugins_toml_after_fallback_only_session_and_clears_direct_atof(
tmp_path,
monkeypatch,
):
fake = _FakeNemoRelay()
initialize_calls = 0
async def _flaky_initialize(config):
nonlocal initialize_calls
initialize_calls += 1
fake.events.append(("plugin.initialize.attempt", initialize_calls, config))
if initialize_calls == 1:
raise RuntimeError("boom")
return {"diagnostics": []}
fake.plugin.initialize = _flaky_initialize
plugin = _fresh_plugin(monkeypatch, fake)
plugins_toml = tmp_path / "plugins.toml"
plugins_toml.write_text(
f"""
version = 1
[[components]]
kind = "observability"
enabled = true
[components.config.atof]
enabled = true
output_directory = "{(tmp_path / "managed-atof").as_posix()}"
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml))
monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_ENABLED", "1")
monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atof"))
plugin.on_session_start(session_id="s1")
plugin.on_session_finalize(session_id="s1", reason="shutdown")
plugin.on_session_start(session_id="s2")
runtime = plugin._get_runtime()
assert runtime is not None
assert runtime._plugin_config_initialized is True
event_names = [event[0] for event in fake.events]
assert event_names.count("plugin.initialize.attempt") == 2
assert event_names.count("atof.register") == 1
assert event_names.count("atof.deregister") == 1
def test_nemo_relay_adaptive_llm_execution_middleware_preserves_raw_response(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
plugin = _fresh_plugin(monkeypatch, fake)

View file

@ -136,6 +136,101 @@ class TestPartialStreamStubFinishReason:
assert "write_file" in content
# ── Clean stream-end mid-tool-call (no exception, no finish_reason) ─────────
class TestCleanStreamEndMidToolCall:
"""The upstream closes the SSE stream cleanly after delivering a tool
name + the opening '{' of its arguments NO exception, NO finish_reason,
NO [DONE]. Observed live on NVIDIA Nemotron Ultra via the Nous dedicated
endpoint: it stalls/drops during large tool-arg generation.
The mock-builder must NOT stamp this as finish_reason='length' (which
routes it through the max_tokens-boost truncation path and finally
reports the misleading 'Response truncated due to output length limit').
It must route through the partial-stream-stub path so the loop reports
an honest mid-tool-call drop and asks the model to chunk its output.
"""
@patch("run_agent.AIAgent._create_request_openai_client")
@patch("run_agent.AIAgent._close_request_openai_client")
def test_no_finish_reason_partial_tool_args_routes_to_stub(
self, _mock_close, mock_create, monkeypatch,
):
def _clean_ending_stream():
# Reasoning + tool name + the lone opening brace, then the
# generator simply RETURNS (StopIteration) — no raise, no
# finish_reason chunk, no [DONE].
yield _make_stream_chunk(content="\n")
yield _make_stream_chunk(tool_calls=[
_make_tool_call_delta(index=0, tc_id="call_x", name="execute_code"),
])
yield _make_stream_chunk(tool_calls=[
_make_tool_call_delta(index=0, arguments="{"),
])
# falls off the end — clean close, no terminator
mock_client = MagicMock()
mock_client.chat.completions.create.side_effect = (
lambda *a, **kw: _clean_ending_stream()
)
mock_create.return_value = mock_client
agent = _make_agent()
agent._fire_stream_delta = lambda text: None
response = agent._interruptible_streaming_api_call({})
assert response.id == PARTIAL_STREAM_STUB_ID, (
"A clean stream-end mid tool-call (no finish_reason) must be "
"tagged as a partial-stream stub, not a 'stream-<uuid>' "
"truncation — otherwise the loop reports the false 'output "
"length limit' error."
)
assert response.choices[0].finish_reason == FINISH_REASON_LENGTH
assert response.choices[0].message.tool_calls is None, (
"Incomplete tool args must never auto-execute."
)
assert getattr(response, "_dropped_tool_names", None) == ["execute_code"]
@patch("run_agent.AIAgent._create_request_openai_client")
@patch("run_agent.AIAgent._close_request_openai_client")
def test_real_length_truncation_still_uses_uuid_id(
self, _mock_close, mock_create, monkeypatch,
):
"""Control: when the provider DOES send finish_reason='length' with
partial tool args, it is a genuine output cap keep the existing
non-stub behaviour (boost max_tokens and retry)."""
def _capped_stream():
yield _make_stream_chunk(tool_calls=[
_make_tool_call_delta(index=0, tc_id="call_y", name="execute_code"),
])
yield _make_stream_chunk(tool_calls=[
_make_tool_call_delta(index=0, arguments="{"),
])
# Provider explicitly reports the output cap.
yield _make_stream_chunk(finish_reason="length")
mock_client = MagicMock()
mock_client.chat.completions.create.side_effect = (
lambda *a, **kw: _capped_stream()
)
mock_create.return_value = mock_client
agent = _make_agent()
agent._fire_stream_delta = lambda text: None
response = agent._interruptible_streaming_api_call({})
assert response.id != PARTIAL_STREAM_STUB_ID, (
"A provider-reported finish_reason='length' is a real output cap "
"and must keep the existing truncation path, not the stream-drop "
"stub path."
)
assert response.id.startswith("stream-")
assert response.choices[0].finish_reason == FINISH_REASON_LENGTH
# ── Length-continuation prompt branching ──────────────────────────────────
class TestLengthContinuationPromptBranching:

View file

@ -2402,15 +2402,20 @@ class TestConcurrentToolExecution:
def test_concurrent_handles_tool_error(self, agent):
"""If one tool raises, others should still complete."""
tc1 = _mock_tool_call(name="web_search", arguments='{}', call_id="c1")
tc2 = _mock_tool_call(name="web_search", arguments='{}', call_id="c2")
# Distinguish the two calls by their arguments so the error is tied to
# a SPECIFIC tool call rather than invocation order. Concurrent
# execution gives no guarantee that c1's handler runs before c2's, so
# keying the raise on a call-order counter is racy: under thread-pool
# scheduling c2 could be invoked first, take the "first call raises"
# branch, and the error would land in messages[1] instead of
# messages[0]. Keying on args makes the assertion deterministic.
tc1 = _mock_tool_call(name="web_search", arguments='{"q": "boom"}', call_id="c1")
tc2 = _mock_tool_call(name="web_search", arguments='{"q": "ok"}', call_id="c2")
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
messages = []
call_count = [0]
def fake_handle(name, args, task_id, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
if args.get("q") == "boom":
raise RuntimeError("boom")
return "success"
@ -2418,9 +2423,11 @@ class TestConcurrentToolExecution:
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
assert len(messages) == 2
# First tool should have error
# Results are ordered by tool_call_id; c1 raised, c2 succeeded.
assert messages[0]["tool_call_id"] == "c1"
assert "Error" in messages[0]["content"] or "boom" in messages[0]["content"]
# Second tool should succeed
assert messages[1]["tool_call_id"] == "c2"
assert "success" in messages[1]["content"]
def test_concurrent_interrupt_before_start(self, agent):
@ -5788,7 +5795,15 @@ class TestStreamingApiCall:
assert tc[0].function.name == "search"
assert tc[1].function.name == "read"
def test_truncated_tool_call_args_upgrade_finish_reason_to_length(self, agent):
def test_truncated_tool_call_args_no_finish_reason_routes_to_stub(self, agent):
# Stream delivers a tool call with incomplete JSON args and then ENDS
# with no finish_reason (the SSE just stops — no terminator, no
# [DONE]). This is an upstream mid-tool-call drop, NOT an output cap.
# The builder must route it through the partial-stream-stub path
# (id=PARTIAL_STREAM_STUB_ID, tool_calls=None so it can't execute,
# finish_reason=length so the loop's continuation machinery fires with
# chunking guidance) rather than stamping a normal 'length' truncation.
from hermes_constants import PARTIAL_STREAM_STUB_ID
chunks = [
_make_chunk(tool_calls=[_make_tc_delta(0, "call_1", "write_file", '{"path":"x.txt","content":"hel')]),
]
@ -5796,6 +5811,24 @@ class TestStreamingApiCall:
resp = agent._interruptible_streaming_api_call({"messages": []})
assert resp.id == PARTIAL_STREAM_STUB_ID
assert resp.choices[0].finish_reason == "length"
assert resp.choices[0].message.tool_calls is None
assert getattr(resp, "_dropped_tool_names", None) == ["write_file"]
def test_truncated_tool_call_args_with_length_finish_reason_upgrades(self, agent):
# Control: when the provider explicitly reports finish_reason='length'
# alongside incomplete tool args, it IS a genuine output cap. Keep the
# existing behaviour — tool_calls preserved, finish_reason 'length' —
# so the max_tokens-boost truncation retry path still applies.
chunks = [
_make_chunk(tool_calls=[_make_tc_delta(0, "call_1", "write_file", '{"path":"x.txt","content":"hel')]),
_make_chunk(finish_reason="length"),
]
agent.client.chat.completions.create.return_value = iter(chunks)
resp = agent._interruptible_streaming_api_call({"messages": []})
tc = resp.choices[0].message.tool_calls
assert len(tc) == 1
assert tc[0].function.name == "write_file"

View file

@ -88,6 +88,103 @@ def test_lazy_installable_extras_excluded_from_all():
)
def _exact_pins(specs):
pins = {}
for spec in specs:
requirement = spec.split(";", 1)[0].strip()
if "==" not in requirement:
continue
package, version = requirement.split("==", 1)
package = package.split("[", 1)[0].lower().replace("_", "-")
pins[package] = version
return pins
def test_pyproject_aiohttp_pins_match_lazy_slack_pin():
"""Avoid update/lazy-install churn from conflicting aiohttp pins.
pyproject extras (messaging/slack/homeassistant/sms) exact-pin aiohttp.
The Slack lazy-install deps (LAZY_DEPS['platform.slack']) also pin it.
If the two drift, `hermes update` resolves the pyproject pin and
downgrades aiohttp, reopening the CVEs the lazy pin fixed (#31817) —
only for Slack's lazy refresh to upgrade it again on next use.
"""
from tools.lazy_deps import LAZY_DEPS
optional_dependencies = _load_optional_dependencies()
lazy_aiohttp = _exact_pins(LAZY_DEPS["platform.slack"])["aiohttp"]
pyproject_aiohttp_pins = {
extra: pins["aiohttp"]
for extra, specs in optional_dependencies.items()
if "aiohttp" in (pins := _exact_pins(specs))
}
assert pyproject_aiohttp_pins, "expected at least one pyproject extra to pin aiohttp"
mismatches = {
extra: pin
for extra, pin in pyproject_aiohttp_pins.items()
if pin != lazy_aiohttp
}
assert not mismatches, (
"pyproject.toml aiohttp pins must match "
"LAZY_DEPS['platform.slack'] to avoid hermes update downgrading "
"aiohttp before Slack's lazy refresh upgrades it again. "
f"lazy aiohttp=={lazy_aiohttp}; mismatched extras: {mismatches}"
)
def test_pyproject_pins_match_lazy_deps_pins():
"""Generalize #31817 to the whole pin surface, not just aiohttp.
Any package that is exact-pinned in BOTH a pyproject extra and a
`tools/lazy_deps.py` LAZY_DEPS entry must use the SAME version in both
places. When they drift, `hermes update` resolves the pyproject extra
pin and downgrades the package to the older version, reopening whatever
the lazy pin fixed (the aiohttp #31817 case, and the anthropic
CVE-2026-34450/34452 case found alongside it) only for the lazy
refresh to re-upgrade it on next feature use. The lazy pin is the
security-current source of truth; extras must track it.
"""
from tools.lazy_deps import LAZY_DEPS
optional_dependencies = _load_optional_dependencies()
# package -> version, as pinned across all pyproject extras. If an
# extra pins a package at a different version than another extra, that
# is itself a bug (caught below); here we just collect the set.
pyproject_pins: dict[str, set[str]] = {}
for specs in optional_dependencies.values():
for package, version in _exact_pins(specs).items():
pyproject_pins.setdefault(package, set()).add(version)
# package -> version, as pinned across all LAZY_DEPS entries.
lazy_pins: dict[str, set[str]] = {}
for specs in LAZY_DEPS.values():
if isinstance(specs, str):
specs = (specs,)
for package, version in _exact_pins(specs).items():
lazy_pins.setdefault(package, set()).add(version)
shared = sorted(set(pyproject_pins) & set(lazy_pins))
assert shared, "expected at least one package pinned in both pyproject and LAZY_DEPS"
drift = {
package: {
"pyproject": sorted(pyproject_pins[package]),
"lazy_deps": sorted(lazy_pins[package]),
}
for package in shared
if pyproject_pins[package] != lazy_pins[package]
}
assert not drift, (
"pyproject extras pins must match tools/lazy_deps.py LAZY_DEPS pins "
"for every shared package — otherwise `hermes update` downgrades the "
"package below the security-current lazy pin (see #31817). Drift: "
f"{drift}"
)
def test_dev_extra_excluded_from_all():
"""End-user installs should not pull test/lint/debug tooling."""
optional_dependencies = _load_optional_dependencies()

View file

@ -9,6 +9,7 @@ from types import SimpleNamespace
from unittest.mock import patch as mock_patch
import tools.approval as approval_module
from hermes_constants import get_hermes_home
from tools.approval import (
_get_approval_mode,
_smart_approve,
@ -424,6 +425,22 @@ class TestHermesConfigWriteProtection:
dangerous, key, desc = detect_dangerous_command("sed --in-place 's/manual/off/' ~/.hermes/config.yaml")
assert dangerous is True
def test_sed_in_place_absolute_hermes_home_config(self):
config_path = get_hermes_home() / "config.yaml"
dangerous, key, desc = detect_dangerous_command(
f"sed -i 's/manual/off/' {config_path}"
)
assert dangerous is True
assert "hermes config" in desc.lower() or "in-place" in desc.lower()
def test_sed_in_place_absolute_hermes_home_env(self):
env_path = get_hermes_home() / ".env"
dangerous, key, desc = detect_dangerous_command(
f"sed -i 's/API_KEY=.*/API_KEY=x/' {env_path}"
)
assert dangerous is True
assert "hermes config" in desc.lower() or "in-place" in desc.lower()
def test_custom_hermes_home(self):
dangerous, key, desc = detect_dangerous_command("echo x | tee $HERMES_HOME/config.yaml")
assert dangerous is True
@ -437,12 +454,33 @@ class TestHermesConfigWriteProtection:
assert dangerous is True
assert "in-place" in desc.lower() or "perl" in desc.lower()
def test_perl_in_place_absolute_hermes_home_config(self):
config_path = get_hermes_home() / "config.yaml"
dangerous, key, desc = detect_dangerous_command(
f"perl -i -pe 's/approvals.mode: on/approvals.mode: off/' {config_path}"
)
assert dangerous is True
assert "in-place" in desc.lower() or "perl" in desc.lower()
def test_ruby_in_place_config(self):
dangerous, key, desc = detect_dangerous_command(
"ruby -i -pe 'gsub(/manual/, \"off\")' ~/.hermes/config.yaml"
)
assert dangerous is True
def test_ruby_in_place_absolute_hermes_home_env(self):
env_path = get_hermes_home() / ".env"
dangerous, key, desc = detect_dangerous_command(
f"ruby -i -pe 'gsub(/API_KEY=.*/, \"API_KEY=x\")' {env_path}"
)
assert dangerous is True
def test_regular_absolute_config_path_still_uses_project_rule(self):
dangerous, key, desc = detect_dangerous_command(
"sed -i 's/a/b/' /srv/app/config.yaml"
)
assert dangerous is False
def test_perl_in_place_env(self):
dangerous, key, desc = detect_dangerous_command(
"perl -i -pe 's/SECRET=old/SECRET=new/' ~/.hermes/.env"

View file

@ -151,7 +151,13 @@ def _is_gateway_approval_context() -> bool:
return bool(_get_session_platform())
# Sensitive write targets that should trigger approval even when referenced
# via shell expansions like $HOME or $HERMES_HOME.
# via shell expansions like $HOME or $HERMES_HOME, or by the resolved absolute
# active profile home path such as /home/hermes/.hermes/config.yaml. The
# resolved-absolute form is folded into the ~/.hermes/ patterns at detection
# time by _normalize_command_for_detection() — see the rewrite step there — so
# these static patterns stay free of any import-time path snapshot (which would
# go stale when HERMES_HOME is set after this module is imported, e.g. under the
# hermetic test conftest or any deferred-profile-resolution path).
_SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
_HERMES_ENV_PATH = (
r'(?:~\/\.hermes/|'
@ -539,8 +545,49 @@ def _normalize_command_for_detection(command: str) -> str:
command = unicodedata.normalize('NFKC', command)
# Strip shell backslash-escapes: r\m → rm. Prevents \-injection bypass.
command = re.sub(r'\\([^\n])', r'\1', command)
# Strip empty-string literals that split tokens: r''m → rm, r""m → rm.
# Strip empty-string literals that split tokens: r''m → rm, r"\"m → rm.
command = re.sub(r"''|\"\"", '', command)
# Fold the resolved absolute active-profile home path into the canonical
# ~/.hermes/ form so the Hermes config/env patterns catch it. In Docker and
# gateway deployments the agent often references the resolved absolute path
# directly (e.g. `sed -i ... /home/hermes/.hermes/config.yaml`) rather than
# ~, $HOME, or $HERMES_HOME. Done at detection time (not via an import-time
# pattern snapshot) so it tracks the live HERMES_HOME even when that is set
# after this module is imported — as the hermetic test conftest does.
command = _rewrite_resolved_hermes_home(command)
return command
def _rewrite_resolved_hermes_home(command: str) -> str:
"""Rewrite the resolved absolute Hermes home prefix to ``~/.hermes/``.
Resolves the active ``HERMES_HOME`` at call time (and its symlink-resolved
form) and replaces an occurrence of ``<home>/`` in *command* with
``~/.hermes/`` so the static ``_HERMES_CONFIG_PATH`` / ``_HERMES_ENV_PATH``
patterns match. No-op when the path can't be resolved or doesn't appear.
"""
try:
from hermes_constants import get_hermes_home
home = get_hermes_home().expanduser()
candidates = [
str(home).rstrip("/"),
str(home.resolve(strict=False)).rstrip("/"),
]
except Exception:
return command
seen: set[str] = set()
for path in candidates:
if not path or path in seen:
continue
seen.add(path)
# Guard against a degenerate HERMES_HOME (e.g. "/" or "") rewriting
# unrelated paths: require an absolute path with at least one non-root
# component. The active profile home is always a real directory like
# /home/hermes/.hermes or a per-test tempdir, never a bare root.
normalized = path.rstrip("/")
if not normalized.startswith("/") or normalized.count("/") < 2:
continue
command = command.replace(normalized + "/", "~/.hermes/")
return command

122
uv.lock generated
View file

@ -38,7 +38,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.3"
version = "3.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@ -49,59 +49,59 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" },
{ url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" },
{ url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" },
{ url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" },
{ url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" },
{ url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" },
{ url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" },
{ url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" },
{ url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" },
{ url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" },
{ url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" },
{ url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" },
{ url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" },
{ url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
{ url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
{ url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
{ url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
{ url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
{ url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
{ url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
{ url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
{ url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
{ url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
{ url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" },
{ url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" },
{ url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" },
{ url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" },
{ url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" },
{ url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" },
{ url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" },
{ url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" },
{ url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" },
{ url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" },
{ url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" },
{ url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" },
{ url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" },
{ url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" },
{ url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" },
{ url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" },
{ url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" },
{ url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" },
{ url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" },
{ url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" },
{ url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" },
{ url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" },
{ url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" },
{ url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" },
{ url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" },
{ url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" },
{ url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" },
{ url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" },
{ url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" },
{ url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" },
{ url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" },
{ url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" },
{ url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" },
{ url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" },
{ url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" },
{ url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" },
{ url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" },
{ url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" },
{ url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" },
{ url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" },
{ url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" },
]
[[package]]
@ -285,7 +285,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.86.0"
version = "0.87.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -297,9 +297,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/8f/3281edf7c35cbac169810e5388eb9b38678c7ea9867c2d331237bd5dff08/anthropic-0.87.0.tar.gz", hash = "sha256:098fef3753cdd3c0daa86f95efb9c8d03a798d45c5170329525bb4653f6702d0", size = 588982, upload-time = "2026-03-31T17:52:41.697Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" },
{ url = "https://files.pythonhosted.org/packages/0d/02/99bf351933bdea0545a2b6e2d812ed878899e9a95f618351dfa3d0de0e69/anthropic-0.87.0-py3-none-any.whl", hash = "sha256:e2669b86d42c739d3df163f873c51719552e263a3d85179297180fb4fa00a236", size = 472126, upload-time = "2026-03-31T17:52:40.174Z" },
]
[[package]]
@ -1584,14 +1584,14 @@ youtube = [
[package.metadata]
requires-dist = [
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" },
{ name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.3" },
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.3" },
{ name = "aiohttp", marker = "extra == 'slack'", specifier = "==3.13.3" },
{ name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.3" },
{ name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.4" },
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.4" },
{ name = "aiohttp", marker = "extra == 'slack'", specifier = "==3.13.4" },
{ name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.4" },
{ name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" },
{ name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" },
{ name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" },
{ name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.86.0" },
{ name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.87.0" },
{ name = "asyncpg", marker = "extra == 'matrix'", specifier = "==0.31.0" },
{ name = "azure-identity", marker = "extra == 'azure-identity'", specifier = "==1.25.3" },
{ name = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" },

View file

@ -0,0 +1,241 @@
---
sidebar_position: 18
---
# Photon iMessage
Connect Hermes to **iMessage** through [Photon][photon], a managed
service that handles the Apple line allocation and abuse-prevention
layer so you don't have to run your own Mac relay.
The free tier uses Photon's shared iMessage line pool — different
recipients may see different sending numbers, but each conversation
stays stable. The paid Business tier gives every user the same
dedicated number; the plugin supports both, and the free tier is the
recommended starting point.
:::info Free to start
Photon's shared-line pool is free. No subscription is required to send
your first iMessage from Hermes — just a phone number we can bind to
your account.
:::
## Architecture
Inbound messages arrive as **signed webhooks**: Photon POSTs JSON with
an `X-Spectrum-Signature` header to a URL you register, and Hermes'
aiohttp listener verifies the HMAC-SHA256 signature before dispatching
the event into the agent.
Outbound replies go through a small supervised **Node sidecar** that
runs the `spectrum-ts` SDK on loopback. Photon does not currently
expose a public HTTP send-message endpoint — that's a roadmap item on
their side — so until then the sidecar is the only way to call
`Space.send(...)`. The Python plugin starts, supervises, and shuts
down the sidecar automatically. When Photon ships an HTTP send
endpoint we'll retire the sidecar in a follow-up release.
## Prerequisites
- A Photon account — sign up at [app.photon.codes][app]
- **Node.js 18.17 or newer** on PATH (`node --version`)
- A phone number that can receive iMessage (used to bind your account)
- A publicly reachable URL for the webhook receiver — Cloudflare
Tunnel, ngrok, or your own gateway hostname all work
## First-time setup
Either run the unified gateway wizard and pick **Photon iMessage**:
```bash
hermes gateway setup
```
…or run the Photon setup directly (the wizard calls the same flow):
```bash
# Device-code login + project + user + sidecar deps, all in one
hermes photon setup --phone +15551234567
```
The setup:
1. Opens `https://app.photon.codes/` for device approval
2. Creates a Spectrum-enabled project under your account
3. Calls the Spectrum `create-user` endpoint with `type: shared` so
Photon allocates an iMessage line from the free pool
4. Runs `npm install` inside the plugin's sidecar directory
Credentials are stored in `~/.hermes/auth.json` under
`credential_pool.photon` (bearer token) and
`credential_pool.photon_project` (project id + secret).
## Authorizing users
Photon uses the same authorization model as every other Hermes
channel. Choose one approach:
**DM pairing (default).** When an unknown number messages your Photon
line, Hermes replies with a pairing code. Approve it with:
```bash
hermes pairing approve photon <CODE>
```
Use `hermes pairing list` to see pending codes and approved users.
**Pre-authorize specific numbers** (in `~/.hermes/.env`):
```bash
PHOTON_ALLOWED_USERS=+15551234567,+15559876543
```
**Open access** (dev only, in `~/.hermes/.env`):
```bash
PHOTON_ALLOW_ALL_USERS=true
```
When `PHOTON_ALLOWED_USERS` is set, unknown senders are silently
ignored rather than offered a pairing code (the allowlist signals you
deliberately restricted access).
### Require mentions in group chats
By default Hermes responds to every authorized DM and group message.
To make group chats opt-in, enable mention gating (DMs still always
work):
```yaml
gateway:
platforms:
photon:
enabled: true
require_mention: true
```
With `require_mention: true`, group-chat messages are ignored unless
they match a wake-word pattern. The defaults match `Hermes` and
`@Hermes agent` variants. For a custom agent name, set regex patterns:
```yaml
gateway:
platforms:
photon:
require_mention: true
mention_patterns:
- '(?<![\w@])@?amos\b[,:\-]?'
```
Both keys also accept env vars (`PHOTON_REQUIRE_MENTION`,
`PHOTON_MENTION_PATTERNS`). This is the same mention-gating model the
BlueBubbles iMessage channel uses.
## Registering the webhook
Photon needs a public URL it can POST to. Expose your local listener
(default port 8788, path `/photon/webhook`) via Cloudflare Tunnel or
ngrok, then:
```bash
hermes photon webhook register https://YOUR-PUBLIC-URL/photon/webhook
```
The response includes a `signingSecret` — **Photon only returns it
once.** Save it to `~/.hermes/.env`:
```bash
PHOTON_WEBHOOK_SECRET=v0_64-char-hex...
```
The plugin verifies every inbound `POST` against this secret and
rejects deliveries with a timestamp drift greater than 5 minutes.
## Start the gateway
```bash
hermes gateway start --platform photon
```
You'll see something like:
```
[photon] connected — webhook at 0.0.0.0:8788/photon/webhook, sidecar on 127.0.0.1:8789
```
Send an iMessage to your assigned number and Hermes will reply.
## Status & troubleshooting
```bash
hermes photon status
```
Prints:
```
Photon iMessage status
──────────────────────
device token : ✓ stored
project id : 3c90c3cc-0d44-4b50-...
project key : ✓ stored
webhook key : ✓ set
node binary : /usr/bin/node
sidecar deps : ✓ installed
```
Common issues:
- **`sidecar deps : ✗ run hermes photon install-sidecar`** — Node is
installed but `spectrum-ts` isn't. Run the suggested command.
- **`webhook key : ⚠ unset — verification disabled`** — the
plugin will accept ANY POST to the webhook URL, which is unsafe.
Re-run `hermes photon webhook register` and store the secret.
- **`PHOTON_WEBHOOK_PORT` already in use** — set a different port via
`~/.hermes/.env`.
- **Webhook reachable from localhost but Photon can't deliver**
Photon needs a public hostname. Cloudflare Tunnel is the easiest
free option.
## Webhook management
```bash
hermes photon webhook list # show registered hooks
hermes photon webhook delete <webhook-id> # remove one
```
## Limits today
- **Attachments are metadata-only.** Inbound webhooks carry the
filename + MIME type but no download URL — Photon documents an
attachment retrieval endpoint as roadmap.
- **Outbound attachments not wired yet.** Easy to add in the sidecar
once the agent has reason to send them.
- **Photon's free quotas:** 5,000 messages per server per day,
50 new-conversation initiations per shared line per day. Increases
available — email `help@photon.codes`.
## Env vars
| Variable | Default | Notes |
|---------------------------|--------------------|--------------------------------------------|
| `PHOTON_PROJECT_ID` | from `auth.json` | Set by `hermes photon setup` |
| `PHOTON_PROJECT_SECRET` | from `auth.json` | Set by `hermes photon setup` |
| `PHOTON_WEBHOOK_SECRET` | (unset) | From `hermes photon webhook register` |
| `PHOTON_WEBHOOK_PORT` | `8788` | Local port for the aiohttp listener |
| `PHOTON_WEBHOOK_PATH` | `/photon/webhook` | Path under which the listener mounts |
| `PHOTON_WEBHOOK_BIND` | `0.0.0.0` | Bind address for the listener |
| `PHOTON_SIDECAR_PORT` | `8789` | Loopback port for sidecar control |
| `PHOTON_SIDECAR_AUTOSTART`| `true` | Whether the adapter spawns the sidecar |
| `PHOTON_NODE_BIN` | `which node` | Override the Node binary path |
| `PHOTON_HOME_CHANNEL` | (unset) | Default space ID for cron / notifications |
| `PHOTON_HOME_CHANNEL_NAME`| (unset) | Human label for the home channel |
| `PHOTON_ALLOWED_USERS` | (unset) | Comma-separated E.164 allowlist |
| `PHOTON_ALLOW_ALL_USERS` | `false` | Dev only — accept any sender |
| `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_API_HOST` | `spectrum.photon.codes` | Override the Spectrum management API host |
| `PHOTON_DASHBOARD_HOST` | `app.photon.codes` | Override the dashboard / device-login host |
[photon]: https://photon.codes/
[app]: https://app.photon.codes/

View file

@ -647,6 +647,7 @@ const sidebars: SidebarsConfig = {
'user-guide/messaging/mattermost',
'user-guide/messaging/matrix',
'user-guide/messaging/bluebubbles',
'user-guide/messaging/photon',
'user-guide/messaging/google_chat',
'user-guide/messaging/line',
'user-guide/messaging/simplex',