diff --git a/README.md b/README.md index 2c587b81ac5..a8db8cb2c29 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/agent/agent_init.py b/agent/agent_init.py index 62de3f2c540..30bb6d83705 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 3f483789ede..ce066d55640 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -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" diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 53cc31daf6d..04b22c76a68 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -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" diff --git a/agent/tool_executor.py b/agent/tool_executor.py index f908aedb806..36cbad4b886 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -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") diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 42df767ef59..bd80fa269fc 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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 { - const keep = new Set([...$workingSessionIds.get(), ...$pinnedSessionIds.get()]) + const keep = new Set([ + ...$workingSessionIds.get(), + ...$pinnedSessionIds.get(), + ...getRecentlySettledSessionIds() + ]) + const active = $selectedStoredSessionId.get() if (active) { diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index eb2489209cf..ae145c8c612 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -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() { - {availableThemes.map(theme => { - const active = themeName === theme.name + <> +
+ {availableThemes.map(theme => { + const active = themeName === theme.name - return ( - - ) - })} -
+ key={theme.name} + onClick={() => { + triggerHaptic('crisp') + setTheme(theme.name) + }} + type="button" + > + +
+
+
+ {theme.label} +
+
+ {theme.description} +
+
+ {active && ( + + + + )} +
+ + ) + })} + + {showProfileNote && ( +

+ {a.themeProfileNote(activeProfileName)} +

+ )} + } description={a.themeDesc} title={a.themeTitle} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 62ddb4fd581..7eedaee2524 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index f2f4f5effa4..5e5865fb900 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -215,7 +215,8 @@ export const ja = defineLocale({ technical: 'テクニカル', technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。', themeTitle: 'テーマ', - themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。' + themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。', + themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。` }, fieldLabels: defineFieldCopy({ model: 'デフォルトモデル', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 55f0691b2e1..5a4b9743a20 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -219,6 +219,7 @@ export interface Translations { technicalDesc: string themeTitle: string themeDesc: string + themeProfileNote: (profile: string) => string } fieldLabels: Record fieldDescriptions: Record diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 0556540d5c6..38c2ad00f9d 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -209,7 +209,8 @@ export const zhHant = defineLocale({ technical: '技術', technicalDesc: '包含原始工具參數、結果與底層細節。', themeTitle: '主題', - themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。' + themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。', + themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。` }, fieldLabels: defineFieldCopy({ model: '預設模型', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index e3610272696..82d3c478d3a 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -287,7 +287,8 @@ export const zh: Translations = { technical: '技术', technicalDesc: '包含原始工具参数/结果及底层细节。', themeTitle: '主题', - themeDesc: '仅桌面端调色板。所选模式叠加其上。' + themeDesc: '仅桌面端调色板。所选模式叠加其上。', + themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。` }, fieldLabels: defineFieldCopy({ model: '默认模型', diff --git a/apps/desktop/src/lib/gateway-events.test.ts b/apps/desktop/src/lib/gateway-events.test.ts index ad118beb680..d51a943611f 100644 --- a/apps/desktop/src/lib/gateway-events.test.ts +++ b/apps/desktop/src/lib/gateway-events.test.ts @@ -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', () => { diff --git a/apps/desktop/src/lib/gateway-events.ts b/apps/desktop/src/lib/gateway-events.ts index 0da4a8683cc..673d1df8c6d 100644 --- a/apps/desktop/src/lib/gateway-events.ts +++ b/apps/desktop/src/lib/gateway-events.ts @@ -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 { return payload && typeof payload === 'object' ? (payload as Record) : {} } +/** + * 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 { diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index 4254929e34d..7aa8ae20d8a 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -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 => ({ 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([]) + }) +}) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 3dfcb7ff12b..901de43667d 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -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() + +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) + } } } diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index 62d71869ba1..0f117213819 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -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() 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 = (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 mounts. +// Boot-time paint to avoid a flash before 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({ }) 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(() => - 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 diff --git a/apps/desktop/src/themes/profile-theme.test.ts b/apps/desktop/src/themes/profile-theme.test.ts new file mode 100644 index 00000000000..7f2809f71bd --- /dev/null +++ b/apps/desktop/src/themes/profile-theme.test.ts @@ -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) + }) +}) diff --git a/cli.py b/cli.py index 5f42e763979..1c32065cf49 100644 --- a/cli.py +++ b/cli.py @@ -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 diff --git a/gateway/run.py b/gateway/run.py index e0692d85493..6a0995b4d83 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index cfd4ad5f8a6..668200a0a38 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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.`` 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) diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index ff03e84408a..f1f87c7703c 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -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": diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 69011c51a94..1041e8fd0b5 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -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, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2115764d5b5..38331e02bf5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 ` pulls every ref, and this repo has + # thousands of auto-generated branches, so scope the fetch to . # Note: upstream/ 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/ 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 diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index c3e1d7ec3e4..2b4034b2ec5 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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 diff --git a/hermes_cli/win_pty_bridge.py b/hermes_cli/win_pty_bridge.py new file mode 100644 index 00000000000..fe8ca1acb04 --- /dev/null +++ b/hermes_cli/win_pty_bridge.py @@ -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() diff --git a/plugins/observability/nemo_relay/README.md b/plugins/observability/nemo_relay/README.md index b4c6e34d646..ddc27e49110 100644 --- a/plugins/observability/nemo_relay/README.md +++ b/plugins/observability/nemo_relay/README.md @@ -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`: diff --git a/plugins/observability/nemo_relay/__init__.py b/plugins/observability/nemo_relay/__init__.py index fb2d76edcf2..894fa9a23e7 100644 --- a/plugins/observability/nemo_relay/__init__.py +++ b/plugins/observability/nemo_relay/__init__.py @@ -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() diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md new file mode 100644 index 00000000000..5af7f02b8b2 --- /dev/null +++ b/plugins/platforms/photon/README.md @@ -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": "", "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/ diff --git a/plugins/platforms/photon/__init__.py b/plugins/platforms/photon/__init__.py new file mode 100644 index 00000000000..7eff97ee0d0 --- /dev/null +++ b/plugins/platforms/photon/__init__.py @@ -0,0 +1,4 @@ +"""Photon Spectrum (iMessage) platform plugin entry point.""" +from .adapter import register + +__all__ = ["register"] diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py new file mode 100644 index 00000000000..1b49d6cef86 --- /dev/null +++ b/plugins/platforms/photon/adapter.py @@ -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"(? 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;+;`. 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;+;` 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, + ) diff --git a/plugins/platforms/photon/auth.py b/plugins/platforms/photon/auth.py new file mode 100644 index 00000000000..e40edd66b4c --- /dev/null +++ b/plugins/platforms/photon/auth.py @@ -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: ("" 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() diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py new file mode 100644 index 00000000000..615ed9db14a --- /dev/null +++ b/plugins/platforms/photon/cli.py @@ -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 "" diff --git a/plugins/platforms/photon/plugin.yaml b/plugins/platforms/photon/plugin.yaml new file mode 100644 index 00000000000..ebdce35ed57 --- /dev/null +++ b/plugins/platforms/photon/plugin.yaml @@ -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 diff --git a/plugins/platforms/photon/sidecar/README.md b/plugins/platforms/photon/sidecar/README.md new file mode 100644 index 00000000000..eb5c2509424 --- /dev/null +++ b/plugins/platforms/photon/sidecar/README.md @@ -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. diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs new file mode 100644 index 00000000000..b6f0c51ef57 --- /dev/null +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -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")); diff --git a/plugins/platforms/photon/sidecar/package.json b/plugins/platforms/photon/sidecar/package.json new file mode 100644 index 00000000000..a651d6adede --- /dev/null +++ b/plugins/platforms/photon/sidecar/package.json @@ -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" + } +} diff --git a/pyproject.toml b/pyproject.toml index fcfd8d773aa..54a54da0409 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/run_agent.py b/run_agent.py index 6a1304f42f9..9c720bcbfe0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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, diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 3965435c1c1..ab116b6699d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh index db3ae5b8bb6..88e12399566 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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" diff --git a/scripts/release.py b/scripts/release.py index adeef29902b..81e63d4a75b 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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) } diff --git a/scripts/run_tests_parallel.py b/scripts/run_tests_parallel.py index 7fe0b57947a..be8bba8ad20 100755 --- a/scripts/run_tests_parallel.py +++ b/scripts/run_tests_parallel.py @@ -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 diff --git a/tests/gateway/test_42039_duplicate_user_message.py b/tests/gateway/test_42039_duplicate_user_message.py new file mode 100644 index 00000000000..0f39c74afc0 --- /dev/null +++ b/tests/gateway/test_42039_duplicate_user_message.py @@ -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 + ) diff --git a/tests/hermes_cli/test_auth_codex_provider.py b/tests/hermes_cli/test_auth_codex_provider.py index 52a8a4a2c45..cb85cf6818e 100644 --- a/tests/hermes_cli/test_auth_codex_provider.py +++ b/tests/hermes_cli/test_auth_codex_provider.py @@ -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) diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index b53e73737ed..1723c11e32c 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -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" diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index 04d10018e27..bf77e7970af 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -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() diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 8457784c78b..a6db6c669de 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_web_server_pty_import.py b/tests/hermes_cli/test_web_server_pty_import.py new file mode 100644 index 00000000000..8a11f77195d --- /dev/null +++ b/tests/hermes_cli/test_web_server_pty_import.py @@ -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 diff --git a/tests/hermes_cli/test_win_pty_bridge.py b/tests/hermes_cli/test_win_pty_bridge.py new file mode 100644 index 00000000000..a7f97b693b1 --- /dev/null +++ b/tests/hermes_cli/test_win_pty_bridge.py @@ -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() diff --git a/tests/plugins/platforms/photon/test_auth.py b/tests/plugins/platforms/photon/test_auth.py new file mode 100644 index 00000000000..a8a5610a4fb --- /dev/null +++ b/tests/plugins/platforms/photon/test_auth.py @@ -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 "" 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) diff --git a/tests/plugins/platforms/photon/test_inbound.py b/tests/plugins/platforms/photon/test_inbound.py new file mode 100644 index 00000000000..00ddcfe4620 --- /dev/null +++ b/tests/plugins/platforms/photon/test_inbound.py @@ -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 diff --git a/tests/plugins/platforms/photon/test_mention_gating.py b/tests/plugins/platforms/photon/test_mention_gating.py new file mode 100644 index 00000000000..3eaf6de22a0 --- /dev/null +++ b/tests/plugins/platforms/photon/test_mention_gating.py @@ -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"(? 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 diff --git a/tests/plugins/platforms/photon/test_signature.py b/tests/plugins/platforms/photon/test_signature.py new file mode 100644 index 00000000000..6f5ec734986 --- /dev/null +++ b/tests/plugins/platforms/photon/test_signature.py @@ -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", + ) diff --git a/tests/plugins/test_nemo_relay_plugin.py b/tests/plugins/test_nemo_relay_plugin.py index ed1e67cbfab..948e80f1e0f 100644 --- a/tests/plugins/test_nemo_relay_plugin.py +++ b/tests/plugins/test_nemo_relay_plugin.py @@ -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) diff --git a/tests/run_agent/test_partial_stream_finish_reason.py b/tests/run_agent/test_partial_stream_finish_reason.py index 77aea3353e2..80474a97310 100644 --- a/tests/run_agent/test_partial_stream_finish_reason.py +++ b/tests/run_agent/test_partial_stream_finish_reason.py @@ -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-' " + "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: diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 884f9995ac1..d215c7b193a 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -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" diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 4ad532c7c26..6c761cb2cdb 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -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() diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index dc9eace274c..b7598380708 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -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" diff --git a/tools/approval.py b/tools/approval.py index 85ae2b9d7f6..2fba7e1101b 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -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 ``/`` 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 diff --git a/uv.lock b/uv.lock index f231eda5536..e7d487bf636 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, diff --git a/website/docs/user-guide/messaging/photon.md b/website/docs/user-guide/messaging/photon.md new file mode 100644 index 00000000000..d6f533c9e77 --- /dev/null +++ b/website/docs/user-guide/messaging/photon.md @@ -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 +``` + +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: + - '(? # 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/ diff --git a/website/sidebars.ts b/website/sidebars.ts index 7705ca565a0..149630b14f6 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -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',