Merge origin/main into bb/desktop-profile-support

Resolve conflicts in desktop settings/cron/messaging/sidebar: adopt main's
ListRow + actions-menu refactors for credential rows; keep our profileColor
import on the sidebar. Drop the now-orphaned Tip-based helpers.
This commit is contained in:
Brooklyn Nicholson 2026-06-04 20:17:07 -05:00
commit 89baf02919
144 changed files with 5796 additions and 1681 deletions

View File

@ -3,6 +3,21 @@
.gitignore .gitignore
.gitmodules .gitmodules
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# Virtual environments
venv/
env/
ENV/
# Dependencies # Dependencies
node_modules node_modules
**/node_modules **/node_modules
@ -24,7 +39,20 @@ ui-tui/packages/hermes-ink/dist/
# Environment files # Environment files
.env .env
.env.*
# IDE
.vscode/
.idea/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
# Documentation
*.md *.md
# Runtime data (bind-mounted at /opt/data; must not leak into build context) # Runtime data (bind-mounted at /opt/data; must not leak into build context)

8
.gitattributes vendored
View File

@ -1,2 +1,10 @@
# Auto-generated files — collapse diffs and exclude from language stats # Auto-generated files — collapse diffs and exclude from language stats
web/package-lock.json linguist-generated=true web/package-lock.json linguist-generated=true
# Enforce LF for scripts that run inside Linux containers.
# Without this, Windows checkout converts to CRLF and breaks `exec` in the
# container entrypoint with "no such file or directory".
*.sh text eol=lf
Dockerfile text eol=lf
*.dockerfile text eol=lf
docker/entrypoint.sh text eol=lf

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
.DS_Store .DS_Store
/venv/ /venv/
/venv.old/
/_pycache/ /_pycache/
*.pyc* *.pyc*
__pycache__/ __pycache__/

View File

@ -33,7 +33,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
### Linux, macOS, WSL2, Termux ### Linux, macOS, WSL2, Termux
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
``` ```
### Windows (native, PowerShell) ### Windows (native, PowerShell)
@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri
Run this in PowerShell: Run this in PowerShell:
```powershell ```powershell
iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) iex (irm https://hermes-agent.nousresearch.com/install.ps1)
``` ```
The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands. The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands.
@ -52,7 +52,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. > **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. 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).
After installation: After installation:

View File

@ -31,7 +31,7 @@
## 快速安装 ## 快速安装
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
``` ```
支持 Linux、macOS、WSL2 和 Android (Termux)。安装程序会自动处理平台特定的配置。 支持 Linux、macOS、WSL2 和 Android (Termux)。安装程序会自动处理平台特定的配置。

View File

@ -457,12 +457,7 @@ class SessionManager:
else: else:
# Update model_config (contains cwd) if changed. # Update model_config (contains cwd) if changed.
try: try:
with db._lock: db.update_session_meta(state.session_id, cwd_json, model_str)
db._conn.execute(
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
(cwd_json, model_str, state.session_id),
)
db._conn.commit()
except Exception: except Exception:
logger.debug("Failed to update ACP session metadata", exc_info=True) logger.debug("Failed to update ACP session metadata", exc_info=True)

View File

@ -441,6 +441,10 @@ def is_local_endpoint(base_url: str) -> bool:
# Docker / Podman / Lima internal DNS names (e.g. host.docker.internal) # Docker / Podman / Lima internal DNS names (e.g. host.docker.internal)
if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES): if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES):
return True return True
# Unqualified hostnames (no dots) are local by definition — Docker
# Compose service names, /etc/hosts entries, or mDNS names.
if host and "." not in host:
return True
# RFC-1918 private ranges, link-local, and Tailscale CGNAT # RFC-1918 private ranges, link-local, and Tailscale CGNAT
try: try:
addr = ipaddress.ip_address(host) addr = ipaddress.ip_address(host)

View File

@ -27,7 +27,7 @@
Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go: Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --include-desktop curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop
``` ```
Already have the Hermes CLI? Just run: Already have the Hermes CLI? Just run:
@ -40,7 +40,7 @@ It builds and launches the GUI against your existing install — same config, ke
### Prebuilt installers ### Prebuilt installers
When a release ships desktop installers they're attached to its [releases page](https://github.com/NousResearch/hermes-agent/releases) — `.dmg` (macOS), `.exe` / `.msi` (Windows), `.AppImage` / `.deb` / `.rpm` (Linux). These are published manually, so the install-with-Hermes path above is the most reliable way to get the latest. Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop).
--- ---
@ -56,10 +56,7 @@ hermes update
## Requirements ## Requirements
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep). The only thing worth knowing: The installer handles everything for you (Python 3.11+, a portable Git, ripgrep).
- **Windows** — the installer bundles its own Git and Python; no admin rights or system changes required.
- **macOS / Linux** — uses your system Python 3.11+ (installed automatically if missing).
--- ---

View File

@ -16,6 +16,7 @@ interface SlashItemMetadata extends Record<string, string> {
command: string command: string
display: string display: string
meta: string meta: string
rawText: string
} }
function textValue(value: unknown, fallback = ''): string { function textValue(value: unknown, fallback = ''): string {
@ -91,7 +92,13 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
const metadata: SlashItemMetadata = { const metadata: SlashItemMetadata = {
command, command,
display, display,
meta meta,
// Provide rawText so hermesDirectiveFormatter.serialize uses the
// direct-insertion path instead of the legacy @type:id fallback.
// Without this, the item.id (which includes a "|index" suffix for
// trigger-adapter uniqueness) leaks into the serialized chip text
// and the submitted command.
rawText: command
} }
return { return {

View File

@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query' import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer' import { useResizeObserver } from '@/hooks/use-resize-observer'
import { chatMessageText } from '@/lib/chat-messages' import { chatMessageText } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -1056,7 +1057,19 @@ export function ChatBar({
if (queueEdit) { if (queueEdit) {
exitQueuedEdit('save') exitQueuedEdit('save')
} else if (busy) { } else if (busy) {
if (hasComposerPayload) { // Slash commands should execute immediately even while the agent is
// busy — they're client-side operations (/yolo, /skin, /new, /help,
// etc.) or self-contained gateway RPCs (/status, /compress). onSubmit
// routes them to executeSlashCommand, which has its own per-command
// busy guard for commands that genuinely need an idle session (skill
// /send directives). Queuing them would make every slash command wait
// for the current turn to finish, which is how the TUI never behaves.
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
const submitted = draft
triggerHaptic('submit')
clearDraft()
void onSubmit(submitted)
} else if (hasComposerPayload) {
queueCurrentDraft() queueCurrentDraft()
} else { } else {
// Stop button: an explicit interrupt must actually halt the running // Stop button: an explicit interrupt must actually halt the running

View File

@ -37,6 +37,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip' import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { profileColor } from '@/lib/profile-color' import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
$panesFlipped, $panesFlipped,
@ -363,11 +364,10 @@ export function ChatSidebar({
return [] return []
} }
const needle = trimmedQuery.toLowerCase()
const out = new Map<string, SessionInfo>() const out = new Map<string, SessionInfo>()
for (const s of sortedSessions) { for (const s of sortedSessions) {
if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) { if (sessionMatchesSearch(s, trimmedQuery)) {
out.set(s.id, s) out.set(s.id, s)
} }
} }

View File

@ -158,7 +158,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
} }
return sorted.filter(session => { return sorted.filter(session => {
const haystack = `${sessionTitle(session)} ${session.id}`.toLowerCase() const haystack = `${sessionTitle(session)} ${session.id} ${session._lineage_root_id ?? ''}`.toLowerCase()
return haystack.includes(needle) return haystack.includes(needle)
}) })

View File

@ -27,6 +27,7 @@ import {
Palette, Palette,
Plus, Plus,
Settings, Settings,
Settings2,
Sun, Sun,
Users, Users,
Wrench, Wrench,
@ -105,7 +106,18 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: strin
tab: 'providers&pview=keys' tab: 'providers&pview=keys'
}, },
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' }, { icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'Tools & Keys', tab: 'keys' }, {
icon: KeyRound,
keywords: ['api', 'secrets', 'tokens', 'credentials', 'browser', 'search'],
label: 'Tools & Keys',
tab: 'keys&kview=tools'
},
{
icon: Settings2,
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
label: 'Tools & Keys settings',
tab: 'keys&kview=settings'
},
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' }, { icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' }, { icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' } { icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }

View File

@ -0,0 +1,108 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
interface CronJobActions {
busy?: boolean
isPaused: boolean
title: string
onDelete: () => void
onEdit: () => void
onPauseResume: () => void
onTrigger: () => void
}
interface CronJobActionsMenuProps
extends CronJobActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
}
export function CronJobActionsMenu({
align = 'end',
busy = false,
children,
isPaused,
onDelete,
onEdit,
onPauseResume,
onTrigger,
sideOffset = 6,
title
}: CronJobActionsMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${title}`}
className="w-44"
sideOffset={sideOffset}
>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onPauseResume()
}}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
<span>{isPaused ? 'Resume' : 'Pause'}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onTrigger()
}}
>
<Codicon name="zap" size="0.875rem" />
<span>Trigger now</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onEdit()
}}
>
<Codicon name="edit" size="0.875rem" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('warning')
onDelete()
}}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
title: string
}
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
return (
<Button
aria-label={`Actions for ${title}`}
className={className}
size="icon-sm"
title="Cron job actions"
variant="ghost"
{...props}
>
<Codicon className="text-muted-foreground" name="ellipsis" size="0.875rem" />
</Button>
)
}

View File

@ -17,7 +17,6 @@ import { Input } from '@/components/ui/input'
import { SearchField } from '@/components/ui/search-field' import { SearchField } from '@/components/ui/search-field'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { import {
createCronJob, createCronJob,
type CronJob, type CronJob,
@ -29,12 +28,13 @@ import {
updateCronJob updateCronJob
} from '@/hermes' } from '@/hermes'
import { AlertTriangle, Clock } from '@/lib/icons' import { AlertTriangle, Clock } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications' import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayView } from '../overlays/overlay-view' import { OverlayView } from '../overlays/overlay-view'
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
const DEFAULT_DELIVER = 'local' const DEFAULT_DELIVER = 'local'
const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [ const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
@ -548,54 +548,27 @@ function CronJobRow({
)} )}
</button> </button>
<div className="flex shrink-0 items-center gap-0.5"> <div className="flex shrink-0 items-center">
<IconAction <CronJobActionsMenu
aria-label={isPaused ? 'Resume cron' : 'Pause cron'} busy={busy}
disabled={busy} isPaused={isPaused}
onClick={onPauseResume} onDelete={onDelete}
title={isPaused ? 'Resume' : 'Pause'} onEdit={onEdit}
onPauseResume={onPauseResume}
onTrigger={onTrigger}
title={jobTitle(job)}
> >
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" /> <CronJobActionsTrigger
</IconAction> className="text-muted-foreground hover:text-foreground"
<IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now"> onClick={event => event.stopPropagation()}
<Codicon name="zap" size="0.875rem" /> title={jobTitle(job)}
</IconAction> />
<IconAction aria-label="Edit cron" onClick={onEdit} title="Edit"> </CronJobActionsMenu>
<Codicon name="edit" size="0.875rem" />
</IconAction>
<IconAction
aria-label="Delete cron"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title="Delete"
>
<Codicon name="trash" size="0.875rem" />
</IconAction>
</div> </div>
</div> </div>
) )
} }
function IconAction({
children,
className,
title,
...props
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
return (
<Tip label={title}>
<Button
className={cn('text-muted-foreground hover:text-foreground', className)}
size="icon-sm"
variant="ghost"
{...props}
>
{children}
</Button>
</Tip>
)
}
function EmptyState({ function EmptyState({
actionLabel, actionLabel,
description, description,

View File

@ -558,6 +558,7 @@ export function DesktopController() {
busyRef, busyRef,
createBackendSessionForSend, createBackendSessionForSend,
handleSkinCommand, handleSkinCommand,
refreshSessions,
requestGateway, requestGateway,
selectedStoredSessionIdRef, selectedStoredSessionIdRef,
startFreshSessionDraft, startFreshSessionDraft,

View File

@ -8,7 +8,6 @@ import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import { import {
getMessagingPlatforms, getMessagingPlatforms,
type MessagingEnvVarInfo, type MessagingEnvVarInfo,
@ -22,6 +21,8 @@ import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell' import { PageSearchShell } from '../page-search-shell'
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
import { ListRow } from '../settings/primitives'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { PlatformAvatar } from './platform-icon' import { PlatformAvatar } from './platform-icon'
@ -109,6 +110,47 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
help: 'first, all, or off.', help: 'first, all, or off.',
advanced: true advanced: true
}, },
DISCORD_ALLOW_ALL_USERS: {
label: 'Allow all Discord users',
help: 'Development only. When true, anyone can DM the bot without an allowlist.',
advanced: true
},
DISCORD_HOME_CHANNEL: {
label: 'Home channel ID',
help: 'Channel where the bot sends proactive messages (cron output, reminders).',
advanced: true
},
DISCORD_HOME_CHANNEL_NAME: {
label: 'Home channel name',
help: 'Display name for the home channel in logs and status output.',
advanced: true
},
BLUEBUBBLES_ALLOW_ALL_USERS: {
label: 'Allow all iMessage users',
help: 'When true, skip the BlueBubbles allowlist.',
advanced: true
},
MATTERMOST_ALLOW_ALL_USERS: {
label: 'Allow all Mattermost users',
advanced: true
},
MATTERMOST_HOME_CHANNEL: {
label: 'Home channel',
advanced: true
},
QQ_ALLOW_ALL_USERS: {
label: 'Allow all QQ users',
advanced: true
},
QQBOT_HOME_CHANNEL: {
label: 'QQ home channel',
help: 'Default channel or group for cron delivery.',
advanced: true
},
QQBOT_HOME_CHANNEL_NAME: {
label: 'QQ home channel name',
advanced: true
},
SLACK_BOT_TOKEN: { SLACK_BOT_TOKEN: {
label: 'Slack bot token', label: 'Slack bot token',
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.', help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
@ -498,7 +540,7 @@ function PlatformDetail({
<section> <section>
<SectionTitle>Required</SectionTitle> <SectionTitle>Required</SectionTitle>
<div className="mt-3 space-y-4"> <div className="mt-3 grid gap-1">
{requiredFields.length > 0 ? ( {requiredFields.length > 0 ? (
requiredFields.map(field => ( requiredFields.map(field => (
<MessagingField <MessagingField
@ -521,7 +563,7 @@ function PlatformDetail({
{optionalFields.length > 0 && ( {optionalFields.length > 0 && (
<section> <section>
<SectionTitle>Recommended</SectionTitle> <SectionTitle>Recommended</SectionTitle>
<div className="mt-3 space-y-4"> <div className="mt-3 grid gap-1">
{optionalFields.map(field => ( {optionalFields.map(field => (
<MessagingField <MessagingField
edits={edits} edits={edits}
@ -547,7 +589,7 @@ function PlatformDetail({
<DisclosureCaret open={showAdvanced} size="0.875rem" /> <DisclosureCaret open={showAdvanced} size="0.875rem" />
</button> </button>
{showAdvanced && ( {showAdvanced && (
<div className="mt-3 space-y-4"> <div className="mt-3 grid gap-1">
{advancedFields.map(field => ( {advancedFields.map(field => (
<MessagingField <MessagingField
edits={edits} edits={edits}
@ -641,48 +683,48 @@ function MessagingField({
saving: string | null saving: string | null
}) { }) {
const copy = fieldCopy(field) const copy = fieldCopy(field)
const fieldId = `messaging-field-${field.key}`
return ( return (
<div className="space-y-1.5"> <ListRow
<div className="flex flex-wrap items-baseline gap-2"> action={
<label className="text-sm font-medium text-foreground" htmlFor={`messaging-field-${field.key}`}> <div className="flex items-center gap-2">
{copy.label} <Input
</label> className={CREDENTIAL_CONTROL_CLASS}
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>} id={fieldId}
</div> onChange={event => onEdit(field.key, event.target.value)}
<div className="flex items-center gap-2"> placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
<Input type={field.is_password ? 'password' : 'text'}
className="font-mono" value={edits[field.key] || ''}
id={`messaging-field-${field.key}`} />
onChange={event => onEdit(field.key, event.target.value)} {field.url && (
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder} <Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''}
/>
{field.url && (
<Tip label="Open docs">
<Button asChild size="icon-sm" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank"> <a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" /> <ExternalLink className="size-3.5" />
</a> </a>
</Button> </Button>
</Tip> )}
)} {field.is_set && (
{field.is_set && (
<Tip label={`Clear ${field.key}`}>
<Button <Button
className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`} disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)} onClick={() => onClear(field.key)}
size="icon-sm" title={`Clear ${field.key}`}
variant="ghost" variant="ghost"
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
</Tip> )}
)} </div>
</div> }
{copy.help && <p className="text-xs leading-5 text-muted-foreground">{copy.help}</p>} description={copy.help}
</div> title={
<span className="flex flex-wrap items-center gap-2">
<label htmlFor={fieldId}>{copy.label}</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
</span>
}
/>
) )
} }

View File

@ -0,0 +1,166 @@
import { cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $sessions, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { usePromptActions } from './use-prompt-actions'
vi.mock('@/hermes', () => ({
transcribeAudio: vi.fn()
}))
// The active id the desktop holds is the *runtime* session id from
// session.create — deliberately distinct from the stored DB id here, because
// that mismatch is the bug: the REST renameSession endpoint resolves against
// the stored sessions table and 404s on a runtime id. session.title accepts
// the runtime id directly.
const RUNTIME_SESSION_ID = 'rt-abc123'
function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
ended_at: null,
id: RUNTIME_SESSION_ID,
input_tokens: 0,
is_active: true,
last_active: 0,
message_count: 3,
model: null,
output_tokens: 0,
preview: null,
source: null,
started_at: 0,
title: 'Old title',
tool_call_count: 0,
...overrides
}
}
interface HarnessHandle {
submitText: (text: string) => Promise<boolean>
}
function Harness({
onReady,
refreshSessions,
requestGateway
}: {
onReady: (handle: HarnessHandle) => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const busyRef = { current: false }
const actions = usePromptActions({
activeSessionId: RUNTIME_SESSION_ID,
activeSessionIdRef,
branchCurrentSession: async () => true,
busyRef,
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
handleSkinCommand: () => '',
refreshSessions,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft: () => undefined,
sttEnabled: false,
updateSessionState: (_sessionId, updater) =>
updater({ messages: [], busy: false, awaitingResponse: false } as never)
})
useEffect(() => {
onReady({ submitText: actions.submitText })
}, [actions.submitText, onReady])
return null
}
describe('usePromptActions /title', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('renames via the session.title RPC (with the runtime id), updates the sidebar store, and refreshes', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) =>
(method === 'session.title' ? { pending: false, title: 'New title' } : {}) as never
)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await handle!.submitText('/title New title')
// Routes through session.title with the runtime session id — NOT the slash
// worker (slash.exec) and NOT the REST endpoint. This is the path that
// resolves the runtime id and persists reliably across platforms.
expect(requestGateway).toHaveBeenCalledWith('session.title', {
session_id: RUNTIME_SESSION_ID,
title: 'New title'
})
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
expect(refreshSessions).toHaveBeenCalledTimes(1)
expect($sessions.get()[0]?.title).toBe('New title')
})
it('reports the queued state when the session row is not persisted yet', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) =>
(method === 'session.title' ? { pending: true, title: 'Fresh chat' } : {}) as never
)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await handle!.submitText('/title Fresh chat')
expect(requestGateway).toHaveBeenCalledWith('session.title', {
session_id: RUNTIME_SESSION_ID,
title: 'Fresh chat'
})
// Even when queued, the sidebar reflects the chosen title optimistically.
expect(refreshSessions).toHaveBeenCalledTimes(1)
expect($sessions.get()[0]?.title).toBe('Fresh chat')
})
it('falls through to the slash worker for a bare /title (show current title)', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await handle!.submitText('/title')
expect(requestGateway).not.toHaveBeenCalledWith('session.title', expect.anything())
expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'title' }))
})
it('surfaces a rename error without touching the sidebar store', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.title') {
throw new Error('Title too long')
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await handle!.submitText('/title way too long title')
expect(requestGateway).toHaveBeenCalledWith('session.title', expect.objectContaining({ title: 'way too long title' }))
expect(refreshSessions).not.toHaveBeenCalled()
expect($sessions.get()[0]?.title).toBe('Old title')
})
})

View File

@ -38,10 +38,11 @@ import {
setAwaitingResponse, setAwaitingResponse,
setBusy, setBusy,
setMessages, setMessages,
setSessions,
setYoloActive setYoloActive
} from '@/store/session' } from '@/store/session'
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types' import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types'
function blobToDataUrl(blob: Blob): Promise<string> { function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -78,6 +79,7 @@ interface PromptActionsOptions {
branchCurrentSession: () => Promise<boolean> branchCurrentSession: () => Promise<boolean>
createBackendSessionForSend: (preview?: string | null) => Promise<string | null> createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
handleSkinCommand: (arg: string) => string handleSkinCommand: (arg: string) => string
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
selectedStoredSessionIdRef: MutableRefObject<string | null> selectedStoredSessionIdRef: MutableRefObject<string | null>
startFreshSessionDraft: () => void startFreshSessionDraft: () => void
@ -142,6 +144,7 @@ export function usePromptActions({
branchCurrentSession, branchCurrentSession,
createBackendSessionForSend, createBackendSessionForSend,
handleSkinCommand, handleSkinCommand,
refreshSessions,
requestGateway, requestGateway,
selectedStoredSessionIdRef, selectedStoredSessionIdRef,
startFreshSessionDraft, startFreshSessionDraft,
@ -501,6 +504,50 @@ export function usePromptActions({
const renderSlashOutput = (text: string) => const renderSlashOutput = (text: string) =>
appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text) appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
// /title <name> renames the session. Route through the gateway's
// `session.title` RPC — the same path the TUI uses — NOT the REST
// renameSession endpoint and NOT the slash worker.
//
// Why not the slash worker: it's a separate HermesCLI subprocess whose
// SQLite write to the shared state.db can silently fail (notably on
// Windows), and it never refreshes the sidebar.
//
// Why not REST renameSession: `sessionId` here is the *runtime* session
// id returned by session.create — it is NOT the stored DB `sessions.id`,
// and session.create deliberately does not persist a DB row until the
// first turn. The REST PATCH endpoint resolves against the sessions
// table, so a runtime id (or a brand-new, not-yet-persisted session)
// 404s with "Session not found" on every platform. See #38508 / #38576.
//
// session.title maps the runtime id to the in-memory session, writes
// through the gateway's own DB connection, and QUEUES the title
// (`pending: true`) when the row isn't persisted yet — so it works for a
// fresh chat too. refreshSessions() then pulls the authoritative title
// back into the sidebar. A bare `/title` (no arg) still falls through to
// the worker to display the current title.
if (normalizedName === 'title' && arg) {
try {
const result = await requestGateway<SessionTitleResponse>('session.title', {
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
await refreshSessions().catch(() => undefined)
renderSlashOutput(
finalTitle
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
: 'Session title cleared.'
)
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
return
}
if (normalizedName === 'skin') { if (normalizedName === 'skin') {
renderSlashOutput(handleSkinCommand(arg)) renderSlashOutput(handleSkinCommand(arg))
@ -601,6 +648,7 @@ export function usePromptActions({
busyRef, busyRef,
createBackendSessionForSend, createBackendSessionForSend,
handleSkinCommand, handleSkinCommand,
refreshSessions,
requestGateway, requestGateway,
startFreshSessionDraft, startFreshSessionDraft,
submitPromptText submitPromptText

View File

@ -0,0 +1,363 @@
import { type ChangeEvent, type KeyboardEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ChevronDown, ExternalLink, Loader2, Save } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { prettyName, withoutKey } from './helpers'
import { ListRow } from './primitives'
import type { EnvRowProps } from './types'
export type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
/** Matches Advanced / config field controls (ListRow + Input). */
export const CREDENTIAL_CONTROL_CLASS = cn('h-8', CONTROL_TEXT)
export const isKeyVar = (key: string, info: EnvVarInfo) =>
info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
export const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
info.description?.trim() ||
key
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase())
export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: string): string =>
isKeyVar(key, info) ? `Paste ${label} key` : /URL$/i.test(key) ? 'https://…' : 'Optional'
// A single credential field: a set key shows as a filled read-only input
// (redacted value) that edits in place on click. Save appears once typed; a set
// key also offers Remove, and Esc cancels without closing the overlay.
export function KeyField({
info,
placeholder,
rowProps,
varKey
}: {
info: EnvVarInfo
placeholder?: string
rowProps: KeyRowProps
varKey: string
}) {
const { edits, onClear, onSave, saving, setEdits } = rowProps
const editing = edits[varKey] !== undefined
const draft = edits[varKey] ?? ''
const dirty = draft.trim().length > 0
const busy = saving === varKey
const masked = info.redacted_value ?? '••••••••'
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
const cancel = () => setEdits(c => withoutKey(c, varKey))
const update = (e: ChangeEvent<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && dirty) {
void onSave(varKey)
} else if (e.key === 'Escape' && editing) {
e.preventDefault()
e.stopPropagation()
cancel()
}
}
const editType = info.is_password ? 'password' : 'text'
if (info.is_set && !editing) {
return (
<Input
className={cn(CREDENTIAL_CONTROL_CLASS, 'cursor-pointer text-muted-foreground')}
onFocus={startEdit}
readOnly
value={masked}
/>
)
}
return (
<div className="grid gap-1">
<div className="flex items-center gap-2">
<Input
autoFocus={editing}
className={cn(CREDENTIAL_CONTROL_CLASS, 'min-w-0 flex-1')}
onChange={update}
onKeyDown={keydown}
placeholder={placeholder ?? 'Paste key'}
type={editType}
value={draft}
/>
{dirty && (
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{busy ? 'Saving' : 'Save'}
</Button>
)}
</div>
{editing && (
<div className="flex items-center gap-1 text-[0.6875rem]">
{info.is_set && (
<>
<Button
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
type="button"
variant="text"
>
Remove
</Button>
<span className="text-muted-foreground">or</span>
</>
)}
<span className="text-muted-foreground">esc to cancel</span>
</div>
)}
</div>
)
}
function CredentialDocsLink({ href }: { href: string }) {
return (
<a
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
href={href}
onClick={e => e.stopPropagation()}
rel="noreferrer"
target="_blank"
>
Get a key
<ExternalLink className="size-3" />
</a>
)
}
/** One credential row — collapsible; description and docs link expand on click. */
export function CredentialKeyCard({
expanded,
info,
label,
onExpand,
onToggle,
placeholder,
rowProps,
varKey
}: CredentialKeyCardProps) {
const docsUrl = info.url?.trim()
const description = info.description?.trim()
const expandable = Boolean(description || docsUrl)
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-1 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
'size-2 shrink-0 rounded-full',
info.is_set ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
/>
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{label}
</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="min-w-0 sm:justify-self-end"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} />
</div>
</div>
{expandable && expanded && (
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
)
}
/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
const docsUrl = group.docsUrl?.trim()
const description = group.description?.trim()
const expandable = Boolean(description || docsUrl || group.advanced.length > 0)
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-1 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
'size-2 shrink-0 rounded-full',
group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
/>
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{group.name}
</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="min-w-0 sm:justify-self-end"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField
info={group.primary[1]}
placeholder={`Paste ${group.name} key`}
rowProps={rowProps}
varKey={group.primary[0]}
/>
</div>
</div>
{expandable && expanded && (
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{group.advanced.map(([key, info]) => {
const fieldLabel = isKeyVar(key, info)
? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
: friendlyFieldLabel(key, info)
return (
<ListRow
action={
<KeyField
info={info}
placeholder={credentialPlaceholder(key, info, fieldLabel)}
rowProps={rowProps}
varKey={key}
/>
}
key={key}
title={fieldLabel}
/>
)
})}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
)
}
export function credentialRowLabel(varKey: string, info: EnvVarInfo): string {
if (isKeyVar(varKey, info)) {
return prettyName(varKey.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
}
return prettyName(varKey)
}
interface CredentialKeyCardProps {
expanded: boolean
info: EnvVarInfo
label: string
onExpand: () => void
onToggle: () => void
placeholder: string
rowProps: KeyRowProps
varKey: string
}
interface ProviderKeyRowsProps {
expanded: boolean
group: ProviderKeyRowGroup
onExpand: () => void
onToggle: () => void
rowProps: KeyRowProps
}
export interface ProviderKeyRowGroup {
advanced: [string, EnvVarInfo][]
description?: string
docsUrl?: string
hasAnySet: boolean
name: string
primary: [string, EnvVarInfo]
}

View File

@ -1,16 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { Tip } from '@/components/ui/tooltip'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes' import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { Check, Eye, EyeOff, type IconComponent, Save, Trash2 } from '@/lib/icons' import { type IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications' import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes' import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { asText, includesQuery, redactedValue, withoutKey } from './helpers' import { asText, includesQuery, redactedValue, withoutKey } from './helpers'
import { Pill } from './primitives' import { Pill } from './primitives'
import type { EnvRowProps } from './types' import type { EnvRowProps } from './types'
@ -33,143 +27,6 @@ export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string,
) )
} }
function EnvActions({
varKey,
info,
saving,
onEdit,
onClear,
onReveal,
isRevealed,
showReveal = true
}: EnvActionsProps) {
return (
<div className="flex shrink-0 items-center gap-1.5">
{info.url && (
<Button asChild size="xs" variant="ghost">
<a href={info.url} rel="noreferrer" target="_blank">
Docs
</a>
</Button>
)}
{info.is_set && showReveal && (
<Tip label={isRevealed ? 'Hide value' : 'Reveal value'}>
<Button onClick={() => onReveal(varKey)} size="icon-xs" variant="ghost">
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
</Tip>
)}
<Button onClick={onEdit} size="xs" variant="outline">
{info.is_set ? 'Replace' : 'Set'}
</Button>
{info.is_set && (
<Tip label="Clear value">
<Button disabled={saving === varKey} onClick={() => onClear(varKey)} size="icon-xs" variant="ghost">
<Trash2 />
</Button>
</Tip>
)}
</div>
)
}
export function EnvVarRow({
varKey,
info,
edits,
revealed,
saving,
setEdits,
onSave,
onClear,
onReveal,
compact = false
}: EnvRowProps) {
const isEditing = edits[varKey] !== undefined
const isRevealed = revealed[varKey] !== undefined
const value = isRevealed ? revealed[varKey] : info.redacted_value
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
if (compact && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
showReveal={false}
varKey={varKey}
/>
</div>
)
}
return (
<div className="grid gap-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/20 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{varKey}</span>
<Pill tone={info.is_set ? 'primary' : 'muted'}>
{info.is_set && <Check className="size-3" />}
{info.is_set ? 'Set' : 'Not set'}
</Pill>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
varKey={varKey}
/>
</div>
{!isEditing && info.is_set && (
<div
className={cn(
'rounded-md px-3 py-2 font-mono text-xs',
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
)}
>
{value || '---'}
</div>
)}
{isEditing && (
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
type={info.is_password ? 'password' : 'text'}
value={edits[varKey]}
/>
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
<Save />
{saving === varKey ? 'Saving' : 'Save'}
</Button>
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
<Codicon name="close" />
Cancel
</Button>
</div>
)}
</div>
)
}
export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) { export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) {
return ( return (
<div className="mb-3 flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium"> <div className="mb-3 flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
@ -330,17 +187,6 @@ interface CategoryHeadingProps {
title: string title: string
} }
interface EnvActionsProps {
varKey: string
info: EnvVarInfo
saving: string | null
onEdit: () => void
onClear: (key: string) => void
onReveal: (key: string) => void
isRevealed: boolean
showReveal?: boolean
}
interface UseEnvCredentials { interface UseEnvCredentials {
rowProps: Omit<EnvRowProps, 'varKey' | 'info'> rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }> saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>

View File

@ -0,0 +1,130 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Eye, EyeOff, ExternalLink, Trash2 } from '@/lib/icons'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
interface EnvVarActionsMenuProps
extends Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
clearDisabled?: boolean
docsUrl?: string | null
isRevealed?: boolean
isSet: boolean
label: string
onClear?: () => void
onEdit: () => void
onReveal?: () => void
showReveal?: boolean
}
export function EnvVarActionsMenu({
align = 'end',
children,
clearDisabled = false,
docsUrl,
isRevealed = false,
isSet,
label,
onClear,
onEdit,
onReveal,
showReveal = true,
sideOffset = 6
}: EnvVarActionsMenuProps) {
const hasClear = isSet && onClear
const hasReveal = isSet && showReveal && onReveal
const hasDocs = Boolean(docsUrl?.trim())
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${label}`}
className="w-44"
sideOffset={sideOffset}
>
{hasDocs && (
<DropdownMenuItem
onSelect={event => {
event.preventDefault()
triggerHaptic('selection')
window.open(docsUrl!, '_blank', 'noopener,noreferrer')
}}
>
<ExternalLink className="size-3.5" />
<span>Docs</span>
</DropdownMenuItem>
)}
{hasReveal && (
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onReveal()
}}
>
{isRevealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
<span>{isRevealed ? 'Hide value' : 'Reveal value'}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onEdit()
}}
>
<Codicon name="edit" size="0.875rem" />
<span>{isSet ? 'Replace' : 'Set'}</span>
</DropdownMenuItem>
{hasClear && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={clearDisabled}
onSelect={() => {
triggerHaptic('warning')
onClear()
}}
variant="destructive"
>
<Trash2 className="size-3.5" />
<span>Clear</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
interface EnvVarActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
label: string
}
export function EnvVarActionsTrigger({ className, label, ...props }: EnvVarActionsTriggerProps) {
return (
<Button
aria-label={`Actions for ${label}`}
className={cn('text-muted-foreground hover:text-foreground', className)}
size="icon-sm"
title="Credential actions"
variant="ghost"
{...props}
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
)
}

View File

@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes' import type { HermesConfigRecord } from '@/types/hermes'
import { getNested, providerGroup, setNested } from './helpers' import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
describe('settings helpers', () => { describe('settings helpers', () => {
it('reads and writes nested config paths', () => { it('reads and writes nested config paths', () => {
@ -21,6 +21,26 @@ describe('settings helpers', () => {
expect(({} as Record<string, unknown>).polluted).toBeUndefined() expect(({} as Record<string, unknown>).polluted).toBeUndefined()
}) })
describe('stripToolsetLabel', () => {
it('removes leading emoji prefixes from registry labels', () => {
expect(stripToolsetLabel('⏰ Cron Jobs')).toBe('Cron Jobs')
expect(stripToolsetLabel('⚡ Code Execution')).toBe('Code Execution')
expect(stripToolsetLabel('❓ Clarifying Questions')).toBe('Clarifying Questions')
expect(stripToolsetLabel('🌐 Browser Automation')).toBe('Browser Automation')
expect(stripToolsetLabel('🎨 Image Generation')).toBe('Image Generation')
})
it('leaves plain titles unchanged', () => {
expect(stripToolsetLabel('Terminal & Processes')).toBe('Terminal & Processes')
})
})
describe('toolsetDisplayLabel', () => {
it('strips emoji from toolset rows', () => {
expect(toolsetDisplayLabel({ name: 'cronjob', label: '⏰ Cron Jobs' })).toBe('Cron Jobs')
})
})
describe('providerGroup', () => { describe('providerGroup', () => {
it('maps a provider env var to its labeled group', () => { it('maps a provider env var to its labeled group', () => {
expect(providerGroup('XAI_API_KEY')).toBe('xAI') expect(providerGroup('XAI_API_KEY')).toBe('xAI')

View File

@ -8,6 +8,13 @@ export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().
export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
/** Strip leading emoji from toolset titles (CLI registry prefixes labels with icons). */
export const stripToolsetLabel = (label: string): string =>
label.replace(/^[\p{Emoji}\p{Extended_Pictographic}\s]+/u, '').trim() || label
export const toolsetDisplayLabel = (toolset: Pick<ToolsetInfo, 'label' | 'name'>): string =>
stripToolsetLabel(asText(toolset.label || toolset.name))
export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : []) export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : [])
export const withoutKey = <T>(record: Record<string, T>, key: string) => { export const withoutKey = <T>(record: Record<string, T>, key: string) => {

View File

@ -4,7 +4,7 @@ import { useRef } from 'react'
import { Tip } from '@/components/ui/tooltip' import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Sparkles, Wrench, Zap } from '@/lib/icons' import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications' import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@ -17,7 +17,7 @@ import { AppearanceSettings } from './appearance-settings'
import { ConfigSettings } from './config-settings' import { ConfigSettings } from './config-settings'
import { SECTIONS } from './constants' import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings' import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings' import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { McpSettings } from './mcp-settings' import { McpSettings } from './mcp-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings' import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings' import { SessionsSettings } from './sessions-settings'
@ -38,12 +38,18 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
// Providers subnav (Accounts vs API keys) lives in its own param so each // Providers subnav (Accounts vs API keys) lives in its own param so each
// sub-view is deep-linkable and survives a refresh. // sub-view is deep-linkable and survives a refresh.
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts') const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
const [keysView, setKeysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
const openProviderView = (view: ProviderView) => { const openProviderView = (view: ProviderView) => {
setActiveView('providers') setActiveView('providers')
setProviderView(view) setProviderView(view)
} }
const openKeysView = (view: KeysView) => {
setActiveView('keys')
setKeysView(view)
}
const importInputRef = useRef<HTMLInputElement | null>(null) const importInputRef = useRef<HTMLInputElement | null>(null)
const exportConfig = async () => { const exportConfig = async () => {
@ -130,6 +136,24 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
label="Tools & Keys" label="Tools & Keys"
onClick={() => setActiveView('keys')} onClick={() => setActiveView('keys')}
/> />
{activeView === 'keys' && (
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={keysView === 'tools'}
icon={Wrench}
label="Tools"
nested
onClick={() => openKeysView('tools')}
/>
<OverlayNavItem
active={keysView === 'settings'}
icon={Settings2}
label="Settings"
nested
onClick={() => openKeysView('settings')}
/>
</div>
)}
<OverlayNavItem <OverlayNavItem
active={activeView === 'mcp'} active={activeView === 'mcp'}
icon={Wrench} icon={Wrench}
@ -196,7 +220,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
) : activeView === 'providers' ? ( ) : activeView === 'providers' ? (
<ProvidersSettings onViewChange={setProviderView} view={providerView} /> <ProvidersSettings onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? ( ) : activeView === 'keys' ? (
<KeysSettings /> <KeysSettings view={keysView} />
) : activeView === 'mcp' ? ( ) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} /> <McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
) : ( ) : (

View File

@ -1,155 +1,83 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Settings2, Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { EnvVarInfo } from '@/types/hermes' import type { EnvVarInfo } from '@/types/hermes'
import { EnvVarRow, useEnvCredentials } from './env-credentials' import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
import { useEnvCredentials } from './env-credentials'
import { asText } from './helpers' import { asText } from './helpers'
import { LoadingState, SettingsContent } from './primitives' import { LoadingState, SettingsContent } from './primitives'
// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx).
export const KEYS_VIEWS = ['tools', 'settings'] as const
export type KeysView = (typeof KEYS_VIEWS)[number]
// Providers live on their own page; messaging-platform credentials live on the // Providers live on their own page; messaging-platform credentials live on the
// dedicated Messaging page (and are hidden here via `channel_managed`). This // dedicated Messaging page (and are hidden here via `channel_managed`). This
// view covers tool API keys plus server/setting env vars (API server, webhook, // view covers tool API keys plus server/setting env vars (API server, webhook,
// gateway), which fold into the Settings tab. // gateway), which fold into the Settings subnav.
const KEY_TABS = [
{ icon: Wrench, id: 'tool', label: 'Tools' },
{ icon: Settings2, id: 'setting', label: 'Settings' }
] as const
type KeyCategoryId = (typeof KEY_TABS)[number]['id'] // Backend categories that surface under each subnav. Platform credentials use the
// `messaging` category but are flagged ``channel_managed`` and configured on
const CATEGORY_LABELS: Record<KeyCategoryId, string> = { // the Messaging page; only gateway-wide ``messaging`` rows (e.g. GATEWAY_PROXY)
setting: 'Settings', // appear here alongside ``setting``.
tool: 'Tools' const VIEW_CATEGORIES: Record<KeysView, readonly string[]> = {
settings: ['setting', 'messaging'],
tools: ['tool']
} }
// Backend categories that surface under each tab. Server/gateway vars carry the export function KeysSettings({ view }: KeysSettingsProps) {
// `messaging` category server-side but belong with general settings here, since
// the platform-credential half of `messaging` is owned by the Messaging page.
const TAB_CATEGORIES: Record<KeyCategoryId, readonly string[]> = {
setting: ['setting', 'messaging'],
tool: ['tool']
}
function tabForCategory(category: string): KeyCategoryId | null {
for (const tab of KEY_TABS) {
if (TAB_CATEGORIES[tab.id].includes(category)) {
return tab.id
}
}
return null
}
function CategoryTabs({
active,
counts,
onSelect
}: {
active: KeyCategoryId
counts: Record<KeyCategoryId, number>
onSelect: (id: KeyCategoryId) => void
}) {
return (
<div className="mb-4 inline-flex w-full gap-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/30 p-1">
{KEY_TABS.map(tab => {
const isActive = active === tab.id
const count = counts[tab.id]
return (
<button
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[length:var(--conversation-text-font-size)] font-medium transition-colors',
isActive
? 'bg-(--ui-chat-surface-background) text-foreground shadow-sm'
: 'text-(--ui-text-secondary) hover:text-foreground'
)}
key={tab.id}
onClick={() => onSelect(tab.id)}
type="button"
>
<tab.icon className="size-3.5 shrink-0" />
<span className="truncate">{tab.label}</span>
{count > 0 && (
<span
className={cn(
'rounded-full px-1.5 text-[0.6875rem] tabular-nums',
isActive ? 'bg-primary/12 text-primary' : 'bg-(--ui-bg-tertiary)/60 text-muted-foreground'
)}
>
{count}
</span>
)}
</button>
)
})}
</div>
)
}
export function KeysSettings() {
const { rowProps, vars } = useEnvCredentials() const { rowProps, vars } = useEnvCredentials()
const [activeCategory, setActiveCategory] = useState<KeyCategoryId>('tool') const [openKey, setOpenKey] = useState<null | string>(null)
useEffect(() => {
setOpenKey(null)
}, [view])
const groups = useMemo(() => { const groups = useMemo(() => {
if (!vars) { if (!vars) {
return [] return []
} }
return KEY_TABS.map(t => t.id).flatMap(tab => { return KEYS_VIEWS.flatMap(v => {
const cats = TAB_CATEGORIES[tab] const cats = VIEW_CATEGORIES[v]
const entries = Object.entries(vars) const entries = Object.entries(vars)
.filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category))) .filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category)))
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
return entries.length === 0 ? [] : [{ category: tab, label: CATEGORY_LABELS[tab], entries }] return entries.length === 0 ? [] : [{ category: v, entries }]
}) })
}, [vars]) }, [vars])
// Tab badge counts reflect how many keys are set per tab. Channel-managed
// credentials are owned by the Messaging page and excluded here.
const categoryCounts = useMemo<Record<KeyCategoryId, number>>(() => {
const counts: Record<KeyCategoryId, number> = { setting: 0, tool: 0 }
if (!vars) {
return counts
}
for (const info of Object.values(vars)) {
if (!info.is_set || info.channel_managed) {
continue
}
const tab = tabForCategory(asText(info.category))
if (tab) {
counts[tab] += 1
}
}
return counts
}, [vars])
if (!vars) { if (!vars) {
return <LoadingState label="Loading API keys and credentials..." /> return <LoadingState label="Loading API keys and credentials..." />
} }
const visible = groups.filter(g => g.category === activeCategory) const visible = groups.filter(g => g.category === view)
return ( return (
<SettingsContent> <SettingsContent>
<CategoryTabs active={activeCategory} counts={categoryCounts} onSelect={setActiveCategory} />
{visible.map(group => ( {visible.map(group => (
<section className="mb-6" key={group.category}> <div className="grid gap-2" key={group.category}>
<div className="grid gap-2"> {group.entries.map(([key, info]: [string, EnvVarInfo]) => {
{group.entries.map(([key, info]: [string, EnvVarInfo]) => ( const label = credentialRowLabel(key, info)
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
))} return (
</div> <CredentialKeyCard
</section> expanded={openKey === key}
info={info}
key={key}
label={label}
onExpand={() => setOpenKey(key)}
onToggle={() => setOpenKey(prev => (prev === key ? null : key))}
placeholder={credentialPlaceholder(key, info, label)}
rowProps={rowProps}
varKey={key}
/>
)
})}
</div>
))} ))}
{visible.length === 0 && ( {visible.length === 0 && (
@ -160,3 +88,7 @@ export function KeysSettings() {
</SettingsContent> </SettingsContent>
) )
} }
interface KeysSettingsProps {
view: KeysView
}

View File

@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { type ChangeEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { import {
FEATURED_ID, FEATURED_ID,
@ -9,43 +9,25 @@ import {
sortProviders sortProviders
} from '@/components/desktop-onboarding-overlay' } from '@/components/desktop-onboarding-overlay'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { listOAuthProviders } from '@/hermes' import { listOAuthProviders } from '@/hermes'
import { ChevronDown, ExternalLink, KeyRound, Loader2, Save } from '@/lib/icons' import { ChevronDown, KeyRound } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding' import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes' import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials' import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority, withoutKey } from './helpers' import { providerGroup, providerMeta, providerPriority } from './helpers'
import { LoadingState, SettingsContent } from './primitives' import { LoadingState, SettingsContent } from './primitives'
import type { EnvRowProps } from './types'
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. // Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
export type ProviderView = (typeof PROVIDER_VIEWS)[number] export type ProviderView = (typeof PROVIDER_VIEWS)[number]
const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key) // Group the env catalog by provider — one ListRow per vendor plus optional
// advanced overrides (base URL, region, etc.). Groups without a key field and
const friendlyFieldLabel = (key: string, info: EnvVarInfo) => // the "Other" bucket are skipped.
info.description?.trim() ||
key
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase())
// Advanced (non-primary) fields are mostly base-URL / endpoint overrides, not
// keys — so don't reuse the "Paste key" placeholder that makes them read as a
// duplicate key input. URL-ish vars get a URL hint; everything else stays optional.
const advancedPlaceholder = (key: string, info: EnvVarInfo): string =>
isKeyVar(key, info) ? 'Paste key' : /URL$/i.test(key) ? 'https://…' : 'Optional'
// Group the env catalog by provider so the keys view can render one collapsible
// row per vendor: a primary key field inline, with any secondary / advanced vars
// (base URL overrides, alt tokens) revealed when the row is focused/expanded.
// Mirrors what Cursor's API-keys section does. Groups without a key field (e.g.
// Nous Portal's lone base-URL override) and the "Other" bucket are skipped.
function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] { function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] {
const buckets = new Map<string, [string, EnvVarInfo][]>() const buckets = new Map<string, [string, EnvVarInfo][]>()
@ -94,228 +76,6 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
} }
// A single credential field: a set key shows as a filled read-only input
// (redacted value) that edits in place on click. Save appears once typed; a set
// key also offers Remove, and Esc cancels without closing the overlay.
function KeyField({
compact = false,
info,
label,
placeholder,
rowProps,
varKey
}: {
compact?: boolean
info: EnvVarInfo
label?: string
placeholder?: string
rowProps: KeyRowProps
varKey: string
}) {
const { edits, onClear, onSave, saving, setEdits } = rowProps
const editing = edits[varKey] !== undefined
const draft = edits[varKey] ?? ''
const dirty = draft.trim().length > 0
const busy = saving === varKey
const masked = info.redacted_value ?? '••••••••'
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
const cancel = () => setEdits(c => withoutKey(c, varKey))
const update = (e: ChangeEvent<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
// Enter saves; Esc cancels in place without bubbling to the overlay's window
// Escape listener (which would otherwise close the whole settings panel).
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && dirty) {
void onSave(varKey)
} else if (e.key === 'Escape' && editing) {
e.preventDefault()
e.stopPropagation()
cancel()
}
}
// Advanced overrides render quieter (xs) than the primary key field so the key
// stays the visual anchor. Padding-driven sizing — no fixed heights.
const inputSize = compact ? 'xs' : 'sm'
const editType = info.is_password ? 'password' : 'text'
// A set value reads as a single filled, read-only field (showing the redacted
// value). Clicking it drops into edit mode in place — no Replace/Cancel chrome.
const control =
info.is_set && !editing ? (
<Input
className="cursor-pointer font-mono text-muted-foreground"
onFocus={startEdit}
readOnly
size={inputSize}
value={masked}
/>
) : (
<div className="grid gap-1">
<div className="flex items-center gap-2">
<Input
autoFocus={editing}
className="min-w-0 flex-1 font-mono"
onChange={update}
onKeyDown={keydown}
placeholder={placeholder ?? 'Paste key'}
size={inputSize}
type={editType}
value={draft}
/>
{dirty && (
<Button disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{busy ? 'Saving' : 'Save'}
</Button>
)}
</div>
{editing && (
<div className="flex items-center gap-1 text-[0.6875rem]">
{info.is_set && (
<>
<Button
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
type="button"
variant="text"
>
Remove
</Button>
<span className="text-muted-foreground">or</span>
</>
)}
<span className="text-muted-foreground">esc to cancel</span>
</div>
)}
</div>
)
// Standard stacked form field: small muted label above, input below. Same shape
// for the primary key and every advanced override — just smaller when compact.
// Empty advanced inputs (not labels) fade back, brightening on hover/focus/set.
const dim = compact && !info.is_set
return (
<div className="grid gap-1.5">
{label && (
<label className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{label}</label>
)}
{dim ? (
<div className="opacity-55 transition-opacity focus-within:opacity-100 hover:opacity-100">{control}</div>
) : (
control
)}
</div>
)
}
function ProviderKeyCard({
expanded,
group,
onExpand,
onToggle,
rowProps
}: {
expanded: boolean
group: ProviderKeyGroup
onExpand: () => void
onToggle: () => void
rowProps: KeyRowProps
}) {
// Expandable when there's anything to reveal — advanced overrides and/or a
// "Get a key" docs link (which lives at the bottom of the expanded panel).
const expandable = group.advanced.length > 0 || Boolean(group.docsUrl)
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-2 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="flex flex-wrap items-start gap-x-4 gap-y-2">
<div className="flex min-w-44 flex-1 items-center gap-2 py-1">
<span
className={cn(
'size-2 shrink-0 rounded-full',
group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
/>
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">{group.name}</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="w-full sm:w-80 sm:shrink-0"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField
info={group.primary[1]}
placeholder={`Paste ${group.name} key`}
rowProps={rowProps}
varKey={group.primary[0]}
/>
</div>
</div>
{expandable && expanded && (
<div className="mt-3 grid gap-2.5 pl-4" onClick={e => e.stopPropagation()}>
{group.advanced.map(([key, info]) => (
<KeyField
compact
info={info}
key={key}
label={isKeyVar(key, info) ? key : friendlyFieldLabel(key, info)}
placeholder={advancedPlaceholder(key, info)}
rowProps={rowProps}
varKey={key}
/>
))}
{group.docsUrl && (
<a
className="inline-flex w-fit items-center gap-1 justify-self-end text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
href={group.docsUrl}
onClick={e => e.stopPropagation()}
rel="noreferrer"
target="_blank"
>
Get a key
<ExternalLink className="size-3" />
</a>
)}
</div>
)}
</div>
)
}
// Deliberately a near-1:1 replica of the first-run onboarding picker // Deliberately a near-1:1 replica of the first-run onboarding picker
// (`Picker` in desktop-onboarding-overlay): same recommended card, same // (`Picker` in desktop-onboarding-overlay): same recommended card, same
// provider rows, same "Other providers" disclosure, same OpenRouter quick-key // provider rows, same "Other providers" disclosure, same OpenRouter quick-key
@ -405,7 +165,6 @@ function NoProviderKeys() {
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) { export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
const { rowProps, vars } = useEnvCredentials() const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([]) const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
// Single-open accordion for the per-provider "advanced options" panels.
const [openProvider, setOpenProvider] = useState<null | string>(null) const [openProvider, setOpenProvider] = useState<null | string>(null)
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we // The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
// re-read connection state when the user finishes (or dismisses) a sign-in // re-read connection state when the user finishes (or dismisses) a sign-in
@ -452,7 +211,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
{keyGroups.length > 0 ? ( {keyGroups.length > 0 ? (
<div className="grid gap-2"> <div className="grid gap-2">
{keyGroups.map(group => ( {keyGroups.map(group => (
<ProviderKeyCard <ProviderKeyRows
expanded={openProvider === group.name} expanded={openProvider === group.name}
group={group} group={group}
key={group.name} key={group.name}
@ -476,8 +235,6 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
) )
} }
type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
interface ProviderKeyGroup { interface ProviderKeyGroup {
advanced: [string, EnvVarInfo][] advanced: [string, EnvVarInfo][]
description?: string description?: string

View File

@ -3,13 +3,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader' import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Tip } from '@/components/ui/tooltip'
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes' import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
import { Check, ExternalLink, Eye, EyeOff, Loader2, Save, Trash2 } from '@/lib/icons' import { Check, Loader2, Save } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications' import { notify, notifyError } from '@/store/notifications'
import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes' import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
import { Pill } from './primitives' import { Pill } from './primitives'
interface ToolsetConfigPanelProps { interface ToolsetConfigPanelProps {
@ -109,33 +109,20 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p> <p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p>
)} )}
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> {!editing && (
{envVar.url && ( <EnvVarActionsMenu
<Button asChild size="xs" variant="ghost"> clearDisabled={busy}
<a href={envVar.url} rel="noreferrer" target="_blank"> docsUrl={envVar.url}
Docs isRevealed={revealed !== null}
<ExternalLink className="size-3" /> isSet={isSet}
</a> label={envVar.key}
</Button> onClear={() => void handleClear()}
)} onEdit={() => setEditing(true)}
{isSet && ( onReveal={() => void handleReveal()}
<Tip label="Reveal value"> >
<Button onClick={() => void handleReveal()} size="icon-xs" variant="ghost"> <EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
{revealed !== null ? <EyeOff /> : <Eye />} </EnvVarActionsMenu>
</Button> )}
</Tip>
)}
<Button onClick={() => setEditing(e => !e)} size="xs" variant="textStrong">
{isSet ? 'Replace' : 'Set'}
</Button>
{isSet && (
<Tip label="Clear value">
<Button disabled={busy} onClick={() => void handleClear()} size="icon-xs" variant="ghost">
<Trash2 />
</Button>
</Tip>
)}
</div>
</div> </div>
{isSet && revealed !== null && ( {isSet && revealed !== null && (

View File

@ -74,6 +74,17 @@ describe('SkillsView toolset management', () => {
await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false)) await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false))
}) })
it('renders toolset titles without leading emoji', async () => {
getToolsets.mockResolvedValue([
toolset({ name: 'cronjob', label: '⏰ Cron Jobs', description: 'cron tools' })
])
await renderSkills()
expect(screen.getByText('Cron Jobs')).toBeTruthy()
expect(screen.queryByText(/⏰/)).toBeNull()
})
it('keeps the configured pill alongside the switch', async () => { it('keeps the configured pill alongside the switch', async () => {
await renderSkills() await renderSkills()

View File

@ -14,7 +14,7 @@ import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_X } from '../layout-constants' import { PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell' import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers' import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel' import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
@ -52,14 +52,17 @@ function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[]
return true return true
} }
const label = toolsetDisplayLabel(toolset)
return ( return (
includesQuery(toolset.name, q) || includesQuery(toolset.name, q) ||
includesQuery(label, q) ||
includesQuery(toolset.label, q) || includesQuery(toolset.label, q) ||
includesQuery(toolset.description, q) || includesQuery(toolset.description, q) ||
toolNames(toolset).some(name => includesQuery(name, q)) toolNames(toolset).some(name => includesQuery(name, q))
) )
}) })
.sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name))) .sort((a, b) => toolsetDisplayLabel(a).localeCompare(toolsetDisplayLabel(b)))
} }
interface SkillsViewProps extends React.ComponentProps<'section'> { interface SkillsViewProps extends React.ComponentProps<'section'> {
@ -167,10 +170,10 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
notify({ notify({
kind: 'success', kind: 'success',
title: enabled ? 'Toolset enabled' : 'Toolset disabled', title: enabled ? 'Toolset enabled' : 'Toolset disabled',
message: `${asText(toolset.label || toolset.name)} applies to new sessions.` message: `${toolsetDisplayLabel(toolset)} applies to new sessions.`
}) })
} catch (err) { } catch (err) {
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`) notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`)
} finally { } finally {
setSavingToolset(null) setSavingToolset(null)
} }
@ -264,7 +267,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div> <div>
{visibleToolsets.map(toolset => { {visibleToolsets.map(toolset => {
const tools = toolNames(toolset) const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name) const label = toolsetDisplayLabel(toolset)
const expanded = expandedToolset === toolset.name const expanded = expandedToolset === toolset.name
return ( return (

View File

@ -25,6 +25,14 @@ export interface SlashExecResponse {
warning?: string warning?: string
} }
export interface SessionTitleResponse {
title?: string
// True when the session row isn't persisted yet and the title was queued
// to be applied on the first turn (see tui_gateway session.title handler).
pending?: boolean
session_key?: string
}
export interface ExecCommandDispatchResponse { export interface ExecCommandDispatchResponse {
type: 'exec' | 'plugin' type: 'exec' | 'plugin'
output?: string output?: string

View File

@ -438,7 +438,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
s.thread.isRunning && s.thread.isRunning &&
s.message.status?.type === 'running' && s.message.status?.type === 'running' &&
s.message.parts s.message.parts
.slice(Math.max(0, startIndex), Math.min(s.message.parts.length, endIndex)) .slice(Math.max(0, startIndex))
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
) )

View File

@ -2,11 +2,23 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { AlertTriangle, FileText, Loader2, RefreshCw, Wrench } from '@/lib/icons' import type { DesktopConnectionConfig } from '@/global'
import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
import { $desktopBoot } from '@/store/boot' import { $desktopBoot } from '@/store/boot'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding } from '@/store/onboarding' import { $desktopOnboarding } from '@/store/onboarding'
type BusyAction = 'local' | 'repair' | 'retry' | null import type { RemoteReauth } from './boot-failure-reauth'
import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null
// A remote gateway whose access cookie has lapsed (e.g. the dashboard
// restarted on the remote box) boots into this overlay with a reauth-shaped
// error. The local-recovery buttons (Retry resets the local bootstrap latch;
// Repair re-runs the installer) are no-ops for that case — the only fix is to
// re-establish the remote session. The detection + copy helpers live in
// ./boot-failure-reauth so they're unit-testable without a React render.
// Recovery surface for a hard boot failure (gateway never came up, backend // Recovery surface for a hard boot failure (gateway never came up, backend
// exited during startup, bootstrap latched, …). Without this the app shell // exited during startup, bootstrap latched, …). Without this the app shell
@ -18,6 +30,7 @@ export function BootFailureOverlay() {
const [busy, setBusy] = useState<BusyAction>(null) const [busy, setBusy] = useState<BusyAction>(null)
const [logs, setLogs] = useState<string[]>([]) const [logs, setLogs] = useState<string[]>([])
const [showLogs, setShowLogs] = useState(false) const [showLogs, setShowLogs] = useState(false)
const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null)
const visible = Boolean(boot.error) && !boot.running const visible = Boolean(boot.error) && !boot.running
// While first-run onboarding owns the picker/flow we let it surface its own // While first-run onboarding owns the picker/flow we let it surface its own
@ -36,6 +49,59 @@ export function BootFailureOverlay() {
.catch(() => undefined) .catch(() => undefined)
}, [visible]) }, [visible])
// Resolve whether this boot failure is a remote-gateway reauth so we can
// offer the actionable "Sign in" path instead of the local-only recovery
// buttons. Runs whenever the overlay becomes visible.
useEffect(() => {
if (!visible) {
setRemoteReauth(null)
return
}
let cancelled = false
void (async () => {
const desktop = window.hermesDesktop
if (!desktop?.getConnectionConfig) {
return
}
let config: DesktopConnectionConfig
try {
config = await desktop.getConnectionConfig()
} catch {
return
}
if (cancelled || !isRemoteReauthFailure(config)) {
return
}
// Best-effort probe for the provider shape so the button copy matches
// what the user will see in the login window (password form vs OAuth
// redirect). Probe failure just keeps the generic copy.
let shape = deriveProviderShape(null)
try {
const probe = await desktop.probeConnectionConfig(config.remoteUrl)
shape = deriveProviderShape(probe?.providers)
} catch {
// Generic copy is fine.
}
if (!cancelled) {
setRemoteReauth({ url: config.remoteUrl, ...shape })
}
})()
return () => {
cancelled = true
}
}, [visible])
if (!visible || suppressed) { if (!visible || suppressed) {
return null return null
} }
@ -59,8 +125,44 @@ export function BootFailureOverlay() {
setBusy(null) setBusy(null)
} }
// Open the gateway's login window (renders the username/password form for a
// basic gateway, or the OAuth redirect otherwise — the desktop drives both
// through the same window). On a successful sign-in the session cookie is
// re-established in the persistent partition; reload so boot re-runs and the
// reconnect now mints a ticket against a live session.
const signInRemote = async () => {
if (!remoteReauth) {
return
}
setBusy('signin')
try {
const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url)
if (result?.connected) {
notify({ kind: 'success', title: 'Signed in', message: 'Reconnecting to the remote gateway…' })
window.location.reload()
return
}
notify({
kind: 'warning',
title: 'Sign-in incomplete',
message: 'The login window closed before authentication finished.'
})
} catch (err) {
notifyError(err, 'Sign-in failed')
} finally {
setBusy(null)
}
}
const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined) const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined)
const label = signInLabel(remoteReauth)
return ( return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6"> <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm"> <div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
@ -69,10 +171,13 @@ export function BootFailureOverlay() {
<AlertTriangle className="size-5" /> <AlertTriangle className="size-5" />
</div> </div>
<div> <div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Hermes couldn't start</h2> <h2 className="text-[0.9375rem] font-semibold tracking-tight">
{remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"}
</h2>
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)"> <p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
The background gateway didn't come up. Try one of the recovery steps below nothing here deletes your {remoteReauth
chats or settings. ? 'Your remote gateway session has expired (the dashboard likely restarted). Sign in again to reconnect — nothing here deletes your chats or settings.'
: "The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your chats or settings."}
</p> </p>
</div> </div>
</div> </div>
@ -84,14 +189,23 @@ export function BootFailureOverlay() {
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button disabled={Boolean(busy)} onClick={() => void retry()}> {remoteReauth ? (
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />} <Button disabled={Boolean(busy)} onClick={() => void signInRemote()}>
Retry {busy === 'signin' ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
</Button> {label}
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline"> </Button>
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />} ) : (
Repair install <Button disabled={Boolean(busy)} onClick={() => void retry()}>
</Button> {busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
Retry
</Button>
)}
{!remoteReauth ? (
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
Repair install
</Button>
) : null}
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline"> <Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null} {busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
Use local gateway Use local gateway
@ -102,7 +216,9 @@ export function BootFailureOverlay() {
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Repair re-runs the installer and can take a few minutes on a fresh machine. {remoteReauth
? 'Opens the gateway login window. Use “Use local gateway” to switch to the bundled backend instead.'
: 'Repair re-runs the installer and can take a few minutes on a fresh machine.'}
</p> </p>
</div> </div>

View File

@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import type { DesktopConnectionConfig } from '@/global'
import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnectionConfig {
return {
envOverride: false,
mode: 'remote',
remoteAuthMode: 'oauth',
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: 'https://box:9119',
...overrides
}
}
describe('isRemoteReauthFailure', () => {
it('true for a remote, gated, disconnected gateway with a URL', () => {
expect(isRemoteReauthFailure(config())).toBe(true)
})
it('false when the oauth session is still connected', () => {
expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }))).toBe(false)
})
it('false for a local gateway', () => {
expect(isRemoteReauthFailure(config({ mode: 'local' }))).toBe(false)
})
it('false for a token (non-gated) remote gateway', () => {
expect(isRemoteReauthFailure(config({ remoteAuthMode: 'token' }))).toBe(false)
})
it('false when there is no remote URL to sign in against', () => {
expect(isRemoteReauthFailure(config({ remoteUrl: '' }))).toBe(false)
})
it('false for null/undefined config', () => {
expect(isRemoteReauthFailure(null)).toBe(false)
expect(isRemoteReauthFailure(undefined)).toBe(false)
})
})
describe('deriveProviderShape', () => {
it('generic copy when there are no providers', () => {
expect(deriveProviderShape([])).toEqual({ isPassword: false, providerLabel: 'your identity provider' })
expect(deriveProviderShape(null)).toEqual({ isPassword: false, providerLabel: 'your identity provider' })
})
it('password shape when the sole provider supports password', () => {
expect(
deriveProviderShape([{ name: 'basic', displayName: 'Username & Password', supportsPassword: true }])
).toEqual({ isPassword: true, providerLabel: 'Username & Password' })
})
it('OAuth shape when the provider is a redirect IDP', () => {
expect(deriveProviderShape([{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }])).toEqual({
isPassword: false,
providerLabel: 'Nous Research'
})
})
it('mixed deployment keeps generic OAuth copy (not every provider is password)', () => {
const shape = deriveProviderShape([
{ name: 'basic', displayName: 'Username & Password', supportsPassword: true },
{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }
])
expect(shape.isPassword).toBe(false)
expect(shape.providerLabel).toBe('Username & Password / Nous Research')
})
it('falls back to name when displayName is empty', () => {
expect(deriveProviderShape([{ name: 'basic', displayName: '', supportsPassword: true }]).providerLabel).toBe(
'basic'
)
})
})
describe('signInLabel', () => {
it('password gateway gets the plain "Sign in to remote gateway" copy', () => {
expect(signInLabel({ url: 'x', isPassword: true, providerLabel: 'Username & Password' })).toBe(
'Sign in to remote gateway'
)
})
it('OAuth gateway names the provider', () => {
expect(signInLabel({ url: 'x', isPassword: false, providerLabel: 'Nous Research' })).toBe(
'Sign in with Nous Research'
)
})
it('null reauth falls back to the generic provider phrase', () => {
expect(signInLabel(null)).toBe('Sign in with your identity provider')
})
})

View File

@ -0,0 +1,67 @@
import type { DesktopAuthProvider, DesktopConnectionConfig } from '@/global'
// Pure helpers for the boot-failure overlay's remote-reauth branch. Kept out
// of the .tsx so they can be unit-tested without a React/jsdom render (the
// jsx-dev-runtime resolution in this repo's vitest setup is flaky for
// component renders, but these are plain functions).
export interface RemoteReauth {
url: string
// True when every advertised provider is username/password — drives the
// button copy ("Sign in to remote gateway" vs "Sign in with <provider>"),
// mirroring the gateway-settings page. Probe is best-effort.
isPassword: boolean
providerLabel: string
}
// A remote, gated (oauth-bucket), not-currently-connected gateway is a
// remote-reauth boot failure: the access cookie lapsed (e.g. the remote
// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't
// fix it — only re-establishing the remote session can. A connected oauth
// session, or a token/local gateway, boots for some other reason the
// local-recovery buttons address, so those return false here.
export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean {
if (!config) {
return false
}
return (
config.mode === 'remote' &&
config.remoteAuthMode === 'oauth' &&
!config.remoteOauthConnected &&
Boolean(config.remoteUrl)
)
}
// Derive the password flag + display label from the probed providers. A
// gateway is treated as password-style only when EVERY advertised provider
// supports password (a mixed deployment keeps the generic OAuth copy), so the
// button copy matches the login window the user is about to see.
export function deriveProviderShape(providers: DesktopAuthProvider[] | null | undefined): {
isPassword: boolean
providerLabel: string
} {
const list = providers ?? []
if (list.length === 0) {
return { isPassword: false, providerLabel: 'your identity provider' }
}
const isPassword = list.every(p => Boolean(p.supportsPassword))
const providerLabel =
list.length === 1
? list[0].displayName || list[0].name
: list.map(p => p.displayName || p.name).join(' / ')
return { isPassword, providerLabel }
}
// Button copy for the remote sign-in action.
export function signInLabel(reauth: RemoteReauth | null): string {
if (reauth?.isPassword) {
return 'Sign in to remote gateway'
}
return `Sign in with ${reauth?.providerLabel ?? 'your identity provider'}`
}

View File

@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { sessionMatchesSearch } from './session-search'
function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
archived: false,
cwd: '/home/user/projects/hermes-agent',
ended_at: null,
id: '20260603_090200_abcd12',
input_tokens: 0,
is_active: false,
last_active: 1_000,
message_count: 2,
model: 'claude',
output_tokens: 0,
preview: 'Fix Desktop session search',
source: 'cli',
started_at: 1_000,
title: 'Desktop Search Feature',
tool_call_count: 0,
...overrides
}
}
describe('sessionMatchesSearch', () => {
it('matches loaded sessions by full and partial session id', () => {
const session = makeSession()
expect(sessionMatchesSearch(session, '20260603_090200_abcd12')).toBe(true)
expect(sessionMatchesSearch(session, '090200')).toBe(true)
expect(sessionMatchesSearch(session, 'ABCD12')).toBe(true)
})
it('matches projected compression sessions by lineage root id', () => {
const session = makeSession({
_lineage_root_id: '20260602_235959_root99',
id: '20260603_010000_tip01'
})
expect(sessionMatchesSearch(session, 'root99')).toBe(true)
expect(sessionMatchesSearch(session, '20260602')).toBe(true)
})
it('preserves title, preview, and workspace matching', () => {
const session = makeSession()
expect(sessionMatchesSearch(session, 'desktop search')).toBe(true)
expect(sessionMatchesSearch(session, 'session search')).toBe(true)
expect(sessionMatchesSearch(session, 'hermes-agent')).toBe(true)
})
it('does not match unrelated queries', () => {
expect(sessionMatchesSearch(makeSession(), 'totally-unrelated')).toBe(false)
})
})

View File

@ -0,0 +1,19 @@
import type { SessionInfo } from '@/types/hermes'
import { sessionTitle } from './chat-runtime'
export function sessionMatchesSearch(session: SessionInfo, query: string): boolean {
const needle = query.trim().toLowerCase()
if (!needle) {
return true
}
return [
session.id,
session._lineage_root_id ?? '',
sessionTitle(session),
session.preview ?? '',
session.cwd ?? ''
].some(value => value.toLowerCase().includes(needle))
}

7
cli.py
View File

@ -7166,7 +7166,11 @@ class HermesCLI:
except Exception: except Exception:
pass pass
# Create the new session with parent link # Create the new session with parent link.
# Persist a stable ``_branched_from`` marker in model_config so
# list_sessions_rich() can keep the branch visible in /resume and
# /sessions even after the parent is reopened and re-ended with a
# different end_reason (e.g. tui_shutdown overwriting 'branched').
try: try:
self._session_db.create_session( self._session_db.create_session(
session_id=new_session_id, session_id=new_session_id,
@ -7175,6 +7179,7 @@ class HermesCLI:
model_config={ model_config={
"max_iterations": self.max_turns, "max_iterations": self.max_turns,
"reasoning_config": self.reasoning_config, "reasoning_config": self.reasoning_config,
"_branched_from": parent_session_id,
}, },
parent_session_id=parent_session_id, parent_session_id=parent_session_id,
) )

View File

@ -967,14 +967,35 @@ def _media_delivery_denied_paths() -> List[Path]:
def _path_under_denied_prefix(resolved: Path) -> bool: def _path_under_denied_prefix(resolved: Path) -> bool:
"""Return True if ``resolved`` lives under a deny-listed system path.""" """Return True if ``resolved`` lives under a deny-listed system path.
One narrow exception: when a denied prefix IS the running user's own home,
the home itself is not treated as denied. ``/root`` is on the system-path
denylist so that a non-root gateway can't deliver another user's home, but
on a root-run gateway ``$HOME=/root`` and the operator's own deliverables
(``/root/work/proposal.docx``) live directly under it. The credential
sub-directories inside home (``~/.ssh``, ``~/.aws``, ...) and Hermes
secrets (``~/.hermes/.env``, ``auth.json``) are *separate, more-specific*
denied paths, so they stay blocked regardless of this exception it can
only un-block a plain file sitting in the running user's home tree, never a
credential location or another user's home.
"""
try:
home = Path(os.path.expanduser("~")).resolve(strict=False)
except (OSError, RuntimeError, ValueError):
home = None
for denied in _media_delivery_denied_paths(): for denied in _media_delivery_denied_paths():
try: try:
resolved_denied = denied.expanduser().resolve(strict=False) resolved_denied = denied.expanduser().resolve(strict=False)
except (OSError, RuntimeError, ValueError): except (OSError, RuntimeError, ValueError):
continue continue
if _path_is_within(resolved, resolved_denied) or resolved == resolved_denied: if not (_path_is_within(resolved, resolved_denied) or resolved == resolved_denied):
return True continue
# Allow the running user's own home tree; its credential sub-dirs are
# caught by their own (more-specific) denylist entries above.
if home is not None and resolved_denied == home:
continue
return True
return False return False

View File

@ -2799,11 +2799,11 @@ class MatrixAdapter(BasePlatformAdapter):
def _markdown_to_html(self, text: str) -> str: def _markdown_to_html(self, text: str) -> str:
"""Convert Markdown to Matrix-compatible HTML (org.matrix.custom.html). """Convert Markdown to Matrix-compatible HTML (org.matrix.custom.html).
Uses the ``markdown`` library when available (installed with the Uses the ``markdown`` library (a core dependency) when available.
``matrix`` extra). Falls back to a comprehensive regex converter Falls back to a comprehensive regex converter that handles fenced
that handles fenced code blocks, inline code, headers, bold, code blocks, inline code, headers, bold, italic, strikethrough,
italic, strikethrough, links, blockquotes, lists, and horizontal links, blockquotes, lists, and horizontal rules everything the
rules everything the Matrix HTML spec allows. Matrix HTML spec allows.
""" """
try: try:
import markdown as _md import markdown as _md

View File

@ -8524,18 +8524,14 @@ class GatewayRunner:
logger.debug("goal continuation hook failed: %s", _goal_exc) logger.debug("goal continuation hook failed: %s", _goal_exc)
return _agent_result return _agent_result
finally: finally:
# If _run_agent replaced the sentinel with a real agent and # Unconditional release covers every exit path. _release_running_agent_state
# then cleaned it up, this is a no-op. If we exited early # is idempotent (pop-on-absent is harmless) and, called without a
# (exception, command fallthrough, etc.) the sentinel must # run_generation guard, always clears the slot regardless of which
# not linger or the session would be permanently locked out. # generation it holds. This evicts the zombie left when session_reset
if self._running_agents.get(_quick_key) is _AGENT_PENDING_SENTINEL: # bumps the generation (N -> N+1) mid-flight: gen-N's guarded release
self._release_running_agent_state(_quick_key) # inside _run_agent returns False, and the old sentinel-only check here
else: # missed the leftover real agent — locking the session out forever (#28686).
# Agent path already cleaned _running_agents; make sure self._release_running_agent_state(_quick_key)
# the paired metadata dicts are gone too.
self._running_agents_ts.pop(_quick_key, None)
if hasattr(self, "_busy_ack_ts"):
self._busy_ack_ts.pop(_quick_key, None)
async def _prepare_inbound_message_text( async def _prepare_inbound_message_text(
self, self,
@ -10032,6 +10028,12 @@ class GatewayRunner:
# Get existing session key # Get existing session key
session_key = self._session_key_for_source(source) session_key = self._session_key_for_source(source)
self._invalidate_session_run_generation(session_key, reason="session_reset") self._invalidate_session_run_generation(session_key, reason="session_reset")
# Evict the running-agent slot now that the generation is bumped. The
# in-flight run's own guarded release (run_generation=old) will return
# False and leave its dead agent behind; clearing here keeps the slot
# from becoming a zombie that silently drops all later messages (#28686).
# Idempotent, so the run's finally calling it again is harmless.
self._release_running_agent_state(session_key)
# Snapshot the old entry so on_session_finalize can report the # Snapshot the old entry so on_session_finalize can report the
# expiring session id before reset_session() rotates it. # expiring session id before reset_session() rotates it.
@ -13954,12 +13956,17 @@ class GatewayRunner:
parent_session_id = current_entry.session_id parent_session_id = current_entry.session_id
# Create the new session with parent link # Create the new session with parent link.
# Persist a stable ``_branched_from`` marker in model_config so
# list_sessions_rich() keeps the branch visible in /resume and
# /sessions even after the parent is reopened and re-ended with a
# different end_reason (e.g. tui_shutdown overwriting 'branched').
try: try:
self._session_db.create_session( self._session_db.create_session(
session_id=new_session_id, session_id=new_session_id,
source=source.platform.value if source.platform else "gateway", source=source.platform.value if source.platform else "gateway",
model=(self.config.get("model", {}) or {}).get("default") if isinstance(self.config, dict) else None, model=(self.config.get("model", {}) or {}).get("default") if isinstance(self.config, dict) else None,
model_config={"_branched_from": parent_session_id},
parent_session_id=parent_session_id, parent_session_id=parent_session_id,
) )
except Exception as e: except Exception as e:

View File

@ -530,7 +530,19 @@ class GatewayStreamConsumer:
if split_at < _safe_limit // 2: if split_at < _safe_limit // 2:
split_at = _safe_limit split_at = _safe_limit
chunk = self._accumulated[:split_at] chunk = self._accumulated[:split_at]
ok = await self._send_or_edit(chunk) # finalize=True so the adapter applies platform-specific
# rich-text markup (e.g. Telegram MarkdownV2). This
# sealed chunk will never be edited again — _message_id
# is reset to None right below — so it must receive its
# final formatting pass now, or early split messages
# render raw markdown while only the last chunk renders.
# is_turn_final=False: this is the first of several split
# messages, NOT the turn-final answer, so the fresh-final
# path (opt-in fresh_final_after_seconds) must not mark
# the turn delivered on it (#29346 semantics).
ok = await self._send_or_edit(
chunk, finalize=True, is_turn_final=False,
)
if self._fallback_final_send or not ok: if self._fallback_final_send or not ok:
# Edit failed (or backed off due to flood control) # Edit failed (or backed off due to flood control)
# while attempting to split an oversized message. # while attempting to split an oversized message.

View File

@ -1322,38 +1322,10 @@ def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]:
return _load_provider_state(auth_store, provider_id) return _load_provider_state(auth_store, provider_id)
def _active_provider_from_store(auth_store: Dict[str, Any]) -> Optional[str]:
"""Return the active provider for a loaded auth store.
In profile mode, falls back to the global-root ``auth.json`` when the
profile store has no ``active_provider`` set. This mirrors the per-provider
shadowing already used by ``_load_provider_state`` and
``read_credential_pool``: a named profile that never selected its own
provider still resolves the provider the user authenticated at the global
root (e.g. a Nous OAuth login), so ``model.provider: auto`` works under a
profile. A profile that has its own ``active_provider`` always wins; the
fallback only fires when the profile has none. Returns ``None`` when
neither scope has one. In classic mode ``_load_global_auth_store`` returns
an empty dict, so this is a no-op. See issue #18594 follow-up.
"""
active = auth_store.get("active_provider")
if active:
return active
global_store = _load_global_auth_store()
if global_store:
return global_store.get("active_provider")
return None
def get_active_provider() -> Optional[str]: def get_active_provider() -> Optional[str]:
"""Return the currently active provider ID from auth store. """Return the currently active provider ID from auth store."""
In profile mode this falls back to the global-root ``active_provider``
when the profile has not selected one of its own see
``_active_provider_from_store``.
"""
auth_store = _load_auth_store() auth_store = _load_auth_store()
return _active_provider_from_store(auth_store) return auth_store.get("active_provider")
def is_provider_explicitly_configured(provider_id: str) -> bool: def is_provider_explicitly_configured(provider_id: str) -> bool:
@ -1575,14 +1547,10 @@ def resolve_provider(
if explicit_api_key or explicit_base_url: if explicit_api_key or explicit_base_url:
return "openrouter" return "openrouter"
# Check auth store for an active OAuth provider. In profile mode this # Check auth store for an active OAuth provider
# honors the global-root active_provider when the profile has none of its
# own, mirroring the credential-pool / provider-state fallbacks so a
# named profile running model.provider: auto can use a globally
# authenticated provider. See issue #18594 follow-up.
try: try:
auth_store = _load_auth_store() auth_store = _load_auth_store()
active = _active_provider_from_store(auth_store) active = auth_store.get("active_provider")
if active and active in PROVIDER_REGISTRY: if active and active in PROVIDER_REGISTRY:
status = get_auth_status(active) status = get_auth_status(active)
if status.get("logged_in"): if status.get("logged_in"):

View File

@ -5958,6 +5958,11 @@ def set_config_value(key: str, value: str):
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER", "terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"terminal.docker_env": "TERMINAL_DOCKER_ENV", "terminal.docker_env": "TERMINAL_DOCKER_ENV",
# JSON-valued keys (terminal_tool parses these via json.loads). The user
# passes JSON on the CLI, so str(value) below already yields valid JSON —
# same as terminal.docker_env. cli.py and gateway/run.py bridge these too.
"terminal.docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"terminal.docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
# terminal.cwd intentionally excluded — CLI resolves at runtime, # terminal.cwd intentionally excluded — CLI resolves at runtime,
# gateway bridges it in gateway/run.py. Persisting to .env causes # gateway bridges it in gateway/run.py. Persisting to .env causes
# stale values to poison child processes. # stale values to poison child processes.

View File

@ -8039,7 +8039,10 @@ def _update_via_zip(args):
# may point to a Python without FTS5. Rebuild it so the new managed # may point to a Python without FTS5. Rebuild it so the new managed
# uv provides a fresh interpreter with FTS5 guaranteed. # uv provides a fresh interpreter with FTS5 guaranteed.
if fresh_bootstrap and uv_bin: if fresh_bootstrap and uv_bin:
rebuild_venv(uv_bin, PROJECT_ROOT / "venv") if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"):
raise RuntimeError(
"venv rebuild failed; aborting update before dependency install"
)
pip_cmd = [sys.executable, "-m", "pip"] pip_cmd = [sys.executable, "-m", "pip"]
if not uv_bin: if not uv_bin:
@ -10224,7 +10227,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
return return
print("✗ Not a git repository. Please reinstall:") print("✗ Not a git repository. Please reinstall:")
print( print(
" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash" " curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
) )
sys.exit(1) sys.exit(1)
@ -10582,7 +10585,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
# may point to a Python without FTS5. Rebuild it so the new managed # may point to a Python without FTS5. Rebuild it so the new managed
# uv provides a fresh interpreter with FTS5 guaranteed. # uv provides a fresh interpreter with FTS5 guaranteed.
if fresh_bootstrap and uv_bin: if fresh_bootstrap and uv_bin:
rebuild_venv(uv_bin, PROJECT_ROOT / "venv") if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"):
raise RuntimeError(
"venv rebuild failed; aborting update before dependency install"
)
pip_cmd = [sys.executable, "-m", "pip"] pip_cmd = [sys.executable, "-m", "pip"]
if not uv_bin: if not uv_bin:

View File

@ -106,41 +106,69 @@ def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> b
fresh interpreter from the current managed uv. Returns ``True`` on fresh interpreter from the current managed uv. Returns ``True`` on
success. success.
On Windows, ``shutil.rmtree(..., ignore_errors=True)`` can silently leave The old venv is moved aside *atomically* (``os.replace`` to ``<venv>.old``)
the venv directory partially intact when another process is holding an before recreating never deleted in place. On Windows a still-running
open handle to a file inside it (typical culprits: a running ``hermes.exe`` (gateway/desktop) holds ``venv\\Scripts\\python.exe`` open;
``hermes.exe`` REPL, the gateway, AV scanners). If we don't notice that ``shutil.rmtree(ignore_errors=True)`` would delete everything it *can*
and just call ``uv venv``, uv refuses with (site-packages, certifi's cert bundle) and silently leave a half-gutted
``Caused by: A directory already exists at: venv`` and the *whole venv that the following ``uv venv`` then refuses to overwrite ("directory
update* falls back to installing on top of the stale venv which has already exists") — bricking the install with no recovery (every later HTTPS
historically produced partial installs where a freshly added dependency call dies with ``FileNotFoundError`` for the missing cert bundle).
(e.g. ``pathspec``) silently fails to land. Retry with ``--clear`` to ``--clear`` alone does not fix this: when the locked interpreter is *inside*
force uv past that condition before giving up. the venv being rebuilt, neither ``rmtree`` nor ``uv venv --clear`` can
delete the held ``python.exe``. ``os.replace`` of the parent directory *is*
allowed (Windows tracks a running ``.exe`` by handle, not path), so the
rebuild completes while the running process keeps using the moved-aside copy
until it restarts. If the venv genuinely cannot be moved, we abort cleanly
and leave it fully intact; and if the rebuild itself fails we move the old
venv back so Hermes is never left with no venv at all.
""" """
backup: Optional[Path] = None
if venv_dir.exists(): if venv_dir.exists():
print(f" → Rebuilding venv (old Python may lack FTS5)...") print(f" → Rebuilding venv (old Python may lack FTS5)...")
shutil.rmtree(venv_dir, ignore_errors=True) backup = venv_dir.with_name(venv_dir.name + ".old")
shutil.rmtree(backup, ignore_errors=True) # clear any stale backup
try:
# Atomic move — fails (without partial deletion) if a process still
# holds files inside the venv, which is exactly the Windows
# file-lock case that previously bricked the install.
os.replace(venv_dir, backup)
except OSError as exc:
logger.warning("venv rebuild aborted — venv in use: %s", exc)
print(
" ✗ venv rebuild aborted — the venv is in use; stop the "
f"gateway/desktop and retry ({exc})"
)
return False
def _run_uv_venv(extra_args: list[str]) -> subprocess.CompletedProcess[str]: result = subprocess.run(
return subprocess.run( [uv_bin, "venv", str(venv_dir), "--python", python_version, "--clear"],
[uv_bin, "venv", str(venv_dir), "--python", python_version, *extra_args], capture_output=True,
capture_output=True, text=True,
text=True, check=False,
check=False, )
)
result = _run_uv_venv([]) def _restore_backup() -> None:
if backup is not None and backup.exists():
# If uv refused because the directory still exists (rmtree above was shutil.rmtree(venv_dir, ignore_errors=True)
# blocked by an open file handle, common on Windows), retry with try:
# --clear so uv overwrites it. Match on stderr because uv's exit code os.replace(backup, venv_dir)
# alone doesn't distinguish "dir exists" from real failures. print(" ↩ Restored previous venv after failed rebuild.")
if result.returncode != 0 and "already exists" in (result.stderr or "").lower(): except OSError:
print(" → venv dir not fully removed (likely an open file handle); retrying with --clear...") pass
result = _run_uv_venv(["--clear"])
if result.returncode == 0: if result.returncode == 0:
venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python" venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python"
# uv can exit 0 yet leave no usable interpreter (e.g. a half-written
# venv). Don't report success on a venv that has no python — restore the
# moved-aside copy so the caller can abort without losing a working env.
if not venv_python.exists():
logger.warning("venv rebuild reported success but %s is missing", venv_python)
print(f" ✗ venv rebuild failed: Python interpreter missing at {venv_python}")
_restore_backup()
return False
if backup is not None:
shutil.rmtree(backup, ignore_errors=True)
py_ver = subprocess.run( py_ver = subprocess.run(
[str(venv_python), "--version"], [str(venv_python), "--version"],
capture_output=True, capture_output=True,
@ -150,6 +178,9 @@ def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> b
print(f" ✓ venv rebuilt ({py_ver})") print(f" ✓ venv rebuilt ({py_ver})")
return True return True
else: else:
# Rebuild failed — restore the old venv so we never leave Hermes with no
# venv (the bricked-install failure mode this function exists to avoid).
_restore_backup()
logger.warning("venv rebuild failed: %s", result.stderr) logger.warning("venv rebuild failed: %s", result.stderr)
print(f" ✗ venv rebuild failed: {result.stderr.strip()}") print(f" ✗ venv rebuild failed: {result.stderr.strip()}")
return False return False

View File

@ -225,13 +225,15 @@ def _probe_single_server(
server = await asyncio.wait_for( server = await asyncio.wait_for(
_connect_server(name, config), timeout=connect_timeout _connect_server(name, config), timeout=connect_timeout
) )
for t in server._tools: try:
desc = getattr(t, "description", "") or "" for t in server._tools:
# Truncate long descriptions for display desc = getattr(t, "description", "") or ""
if len(desc) > 80: # Truncate long descriptions for display
desc = desc[:77] + "..." if len(desc) > 80:
tools_found.append((t.name, desc)) desc = desc[:77] + "..."
await server.shutdown() tools_found.append((t.name, desc))
finally:
await server.shutdown()
try: try:
_run_on_mcp_loop(_probe(), timeout=connect_timeout + 10) _run_on_mcp_loop(_probe(), timeout=connect_timeout + 10)

View File

@ -52,6 +52,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("deepseek/deepseek-v4-flash", ""), ("deepseek/deepseek-v4-flash", ""),
# Qwen # Qwen
("qwen/qwen3.7-max", ""), ("qwen/qwen3.7-max", ""),
("qwen/qwen3.7-plus", ""),
("qwen/qwen3.6-35b-a3b", ""), ("qwen/qwen3.6-35b-a3b", ""),
# MoonshotAI # MoonshotAI
("moonshotai/kimi-k2.6", "recommended"), ("moonshotai/kimi-k2.6", "recommended"),
@ -169,6 +170,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-flash",
# Qwen # Qwen
"qwen/qwen3.7-max", "qwen/qwen3.7-max",
"qwen/qwen3.7-plus",
"qwen/qwen3.6-35b-a3b", "qwen/qwen3.6-35b-a3b",
# MoonshotAI # MoonshotAI
"moonshotai/kimi-k2.6", "moonshotai/kimi-k2.6",
@ -588,7 +590,8 @@ def union_with_portal_free_recommendations(
pair where: pair where:
* Portal free recommendations missing from ``curated_ids`` are * Portal free recommendations missing from ``curated_ids`` are
appended at the front (so the picker shows them first). appended after the curated list (so the in-repo curated models
show first and Portal-only picks follow).
* ``pricing`` gets a synthetic ``{"prompt": "0", "completion": "0"}`` * ``pricing`` gets a synthetic ``{"prompt": "0", "completion": "0"}``
entry for any free recommendation missing from the live pricing entry for any free recommendation missing from the live pricing
map, so :func:`partition_nous_models_by_tier` keeps it. map, so :func:`partition_nous_models_by_tier` keeps it.
@ -623,11 +626,11 @@ def union_with_portal_free_recommendations(
augmented_ids = list(curated_ids) augmented_ids = list(curated_ids)
seen = set(augmented_ids) seen = set(augmented_ids)
# Prepend Portal free recommendations that aren't already curated, so # Append Portal free recommendations that aren't already curated, so the
# they appear first in the picker. # in-repo curated ("HA") models show first and Portal-only picks follow.
new_ones = [mid for mid in portal_free_ids if mid not in seen] new_ones = [mid for mid in portal_free_ids if mid not in seen]
if new_ones: if new_ones:
augmented_ids = new_ones + augmented_ids augmented_ids = augmented_ids + new_ones
return (augmented_ids, augmented_pricing) return (augmented_ids, augmented_pricing)
@ -653,7 +656,8 @@ def union_with_portal_paid_recommendations(
``(model_ids, pricing)`` pair where: ``(model_ids, pricing)`` pair where:
* Portal paid recommendations missing from ``curated_ids`` are * Portal paid recommendations missing from ``curated_ids`` are
appended at the front (so the picker shows them first). appended after the curated list (so the in-repo curated models
show first and Portal-only picks follow).
* ``pricing`` is left untouched we deliberately do NOT synthesize * ``pricing`` is left untouched we deliberately do NOT synthesize
pricing entries for paid models. Live pricing is fetched separately pricing entries for paid models. Live pricing is fetched separately
via :func:`get_pricing_for_provider`; if the live endpoint hasn't via :func:`get_pricing_for_provider`; if the live endpoint hasn't
@ -688,11 +692,11 @@ def union_with_portal_paid_recommendations(
augmented_ids = list(curated_ids) augmented_ids = list(curated_ids)
seen = set(augmented_ids) seen = set(augmented_ids)
# Prepend Portal paid recommendations that aren't already curated, so # Append Portal paid recommendations that aren't already curated, so the
# the Portal-blessed picks surface first in the picker. # in-repo curated ("HA") models show first and Portal-only picks follow.
new_ones = [mid for mid in portal_paid_ids if mid not in seen] new_ones = [mid for mid in portal_paid_ids if mid not in seen]
if new_ones: if new_ones:
augmented_ids = new_ones + augmented_ids augmented_ids = augmented_ids + new_ones
return (augmented_ids, dict(pricing)) return (augmented_ids, dict(pricing))

View File

@ -82,6 +82,22 @@ CONFIGURABLE_TOOLSETS = [
("computer_use", "🖱️ Computer Use (macOS)", "background desktop control via cua-driver"), ("computer_use", "🖱️ Computer Use (macOS)", "background desktop control via cua-driver"),
] ]
def gui_toolset_label(label: str) -> str:
"""Strip leading emoji/icons from toolset titles for GUI surfaces.
Registry labels use ``<emoji> <title>``; plugin toolsets prefix with ``🔌``.
CLI/TUI keeps the raw ``label`` only HTTP APIs call this helper.
"""
text = (label or "").strip()
if not text:
return text
parts = text.split(None, 1)
if len(parts) == 2 and parts[0] and not any(ch.isascii() and ch.isalnum() for ch in parts[0]):
return parts[1].strip()
return text
# Toolsets that are OFF by default for new installs. # Toolsets that are OFF by default for new installs.
# They're still in _HERMES_CORE_TOOLS (available at runtime if enabled), # They're still in _HERMES_CORE_TOOLS (available at runtime if enabled),
# but the setup checklist won't pre-select them for first-time users. # but the setup checklist won't pre-select them for first-time users.

View File

@ -734,9 +734,9 @@ def run_uninstall(args):
print() print()
print("To reinstall later with your existing settings:") print("To reinstall later with your existing settings:")
if _is_windows(): if _is_windows():
print(color(" iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)", Colors.DIM)) print(color(" iex (irm https://hermes-agent.nousresearch.com/install.ps1)", Colors.DIM))
else: else:
print(color(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash", Colors.DIM)) print(color(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", Colors.DIM))
print() print()
if _is_windows(): if _is_windows():

View File

@ -14,11 +14,14 @@ from contextlib import asynccontextmanager
import asyncio import asyncio
import base64 import base64
import binascii import binascii
from dataclasses import dataclass
from datetime import datetime, timezone
import hmac import hmac
import importlib.util import importlib.util
import json import json
import logging import logging
import os import os
import re
import secrets import secrets
import stat import stat
import subprocess import subprocess
@ -26,6 +29,7 @@ import sys
import tempfile import tempfile
import threading import threading
import time import time
import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@ -557,6 +561,14 @@ class MessagingPlatformUpdate(BaseModel):
clear_env: List[str] = [] clear_env: List[str] = []
class TelegramOnboardingStart(BaseModel):
bot_name: Optional[str] = None
class TelegramOnboardingApply(BaseModel):
allowed_user_ids: List[str]
class AudioTranscriptionRequest(BaseModel): class AudioTranscriptionRequest(BaseModel):
data_url: str data_url: str
mime_type: Optional[str] = None mime_type: Optional[str] = None
@ -1720,14 +1732,15 @@ async def get_profiles_sessions(
@app.get("/api/sessions/search") @app.get("/api/sessions/search")
async def search_sessions(q: str = "", limit: int = 20): async def search_sessions(q: str = "", limit: int = 20):
"""Full-text search across session message content using FTS5. """Search sessions by ID plus full-text message content using FTS5.
Results are deduped by compression lineage, not by raw ``session_id``. Direct session-id matches are surfaced first, then FTS message-content
Auto-compression rotates a conversation onto a fresh session id (and leaves matches. Results are deduped by compression lineage, not by raw
the old segment's messages in the FTS index), so one logical chat can own ``session_id``. Auto-compression rotates a conversation onto a fresh
many ``sessions`` rows that all match the same query. Branches also use session id (and leaves the old segment's messages in the FTS index), so one
``parent_session_id``, but they are real alternate conversations; don't logical chat can own many ``sessions`` rows that all match the same query.
collapse branch-specific hits back into the parent. Branches also use ``parent_session_id``, but they are real alternate
conversations; don't collapse branch-specific hits back into the parent.
""" """
if not q or not q.strip(): if not q or not q.strip():
return {"results": []} return {"results": []}
@ -1735,21 +1748,7 @@ async def search_sessions(q: str = "", limit: int = 20):
from hermes_state import SessionDB from hermes_state import SessionDB
db = SessionDB() db = SessionDB()
try: try:
# Auto-add prefix wildcards so partial words match safe_limit = max(1, min(int(limit or 20), 100))
# e.g. "nimb" → "nimb*" matches "nimby"
# Preserve quoted phrases and existing wildcards as-is
import re
terms = []
for token in re.findall(r'"[^"]*"|\S+', q.strip()):
if token.startswith('"') or token.endswith("*"):
terms.append(token)
else:
terms.append(token + "*")
prefix_query = " ".join(terms)
# Over-fetch so lineage dedup can still surface `limit` distinct
# conversations even when several hits collapse onto one root.
fetch_limit = max(limit * 5, 50)
matches = db.search_messages(query=prefix_query, limit=fetch_limit)
# Walk parent_session_id to the compression root, memoized so a # Walk parent_session_id to the compression root, memoized so a
# chain of compression segments only costs one walk. We deliberately # chain of compression segments only costs one walk. We deliberately
@ -1821,24 +1820,71 @@ async def search_sessions(q: str = "", limit: int = 20):
tip_cache[root_id] = tip tip_cache[root_id] = tip
return tip return tip
# Keep the best (first / most relevant) hit per compression root. # Both ID matches and content matches share one keyspace, keyed by
# compression lineage root, so an id-hit and a content-hit on the
# same logical conversation collapse to a single result. The first
# hit for a lineage wins; ID matches run first and take priority.
seen: dict = {} seen: dict = {}
for m in matches:
raw_sid = m["session_id"] def add_lineage_result(raw_sid: str, payload: dict) -> None:
if not raw_sid:
return
root = compression_root(raw_sid) root = compression_root(raw_sid)
if root in seen: if root in seen or len(seen) >= safe_limit:
continue return
seen[root] = { payload = dict(payload)
"session_id": lineage_tip(root), payload["session_id"] = lineage_tip(root)
"lineage_root": root, payload["lineage_root"] = root
"snippet": m.get("snippet", ""), seen[root] = payload
"role": m.get("role"),
"source": m.get("source"), # Direct ID matches first: users often paste a session id from CLI,
"model": m.get("model"), # logs, or another Hermes surface. FTS can't find those unless the
"session_started": m.get("session_started"), # id happens to appear in message text. search_sessions_by_id is
} # SQL-bounded, so this stays cheap even with thousands of sessions.
if len(seen) >= limit: for row in db.search_sessions_by_id(q, limit=safe_limit, include_archived=True):
sid = row.get("id")
preview = (row.get("preview") or "").strip()
snippet = preview or f"Session ID: {sid}"
add_lineage_result(
sid,
{
"snippet": snippet,
"role": None,
"source": row.get("source"),
"model": row.get("model"),
"session_started": row.get("started_at"),
},
)
# Auto-add prefix wildcards so partial words match
# e.g. "nimb" → "nimb*" matches "nimby"
# Preserve quoted phrases and existing wildcards as-is
import re
terms = []
for token in re.findall(r'"[^"]*"|\S+', q.strip()):
if token.startswith('"') or token.endswith("*"):
terms.append(token)
else:
terms.append(token + "*")
prefix_query = " ".join(terms)
# Over-fetch so lineage dedup can still surface `limit` distinct
# conversations even when several hits collapse onto one root.
fetch_limit = max(safe_limit * 5, 50)
matches = db.search_messages(query=prefix_query, limit=fetch_limit)
for m in matches:
if len(seen) >= safe_limit:
break break
add_lineage_result(
m["session_id"],
{
"snippet": m.get("snippet", ""),
"role": m.get("role"),
"source": m.get("source"),
"model": m.get("model"),
"session_started": m.get("session_started"),
},
)
return {"results": list(seen.values())} return {"results": list(seen.values())}
finally: finally:
db.close() db.close()
@ -2908,18 +2954,66 @@ def _channel_managed_env_keys() -> frozenset[str]:
return frozenset() return frozenset()
# Cross-cutting gateway / relay knobs stay on the Keys → Settings tab even though
# they use the ``messaging`` category in OPTIONAL_ENV_VARS. Platform-scoped vars
# (``DISCORD_*``, ``MATRIX_*``, …) are owned by the Messaging UI instead.
_MESSAGING_KEYS_PAGE_KEYS = frozenset({
"GATEWAY_ALLOW_ALL_USERS",
"GATEWAY_PROXY_KEY",
"GATEWAY_PROXY_URL",
})
def _platform_env_prefixes(platform_id: str) -> tuple[str, ...]:
"""Env-var prefixes owned by a messaging platform card."""
aliases: dict[str, tuple[str, ...]] = {
"email": ("EMAIL_",),
"homeassistant": ("HASS_",),
"qqbot": ("QQ_", "QQBOT_"),
"sms": ("TWILIO_",),
"wecom": ("WECOM_BOT_", "WECOM_SECRET"),
"wecom_callback": ("WECOM_CALLBACK_",),
}
if platform_id in aliases:
return aliases[platform_id]
return (platform_id.upper().replace("-", "_") + "_",)
def _discover_platform_env_vars(platform_id: str) -> tuple[str, ...]:
"""All messaging-category env vars for a platform (override + plugin + prefix)."""
prefixes = _platform_env_prefixes(platform_id)
keys: list[str] = []
for name, info in OPTIONAL_ENV_VARS.items():
if info.get("category") != "messaging":
continue
if name in _MESSAGING_KEYS_PAGE_KEYS:
continue
if not any(name.startswith(prefix) for prefix in prefixes):
continue
keys.append(name)
return tuple(sorted(set(keys)))
def _merge_platform_env_vars(
platform_id: str,
override: dict[str, Any],
plugin_entry: Any | None,
) -> tuple[str, ...]:
"""Canonical env-var list for a messaging platform card."""
discovered = _discover_platform_env_vars(platform_id)
if "env_vars" in override:
return tuple(dict.fromkeys((*override["env_vars"], *discovered)))
if plugin_entry is not None and plugin_entry.required_env:
return tuple(dict.fromkeys((*tuple(plugin_entry.required_env), *discovered)))
return discovered
def _build_catalog_entry( def _build_catalog_entry(
platform_id: str, plugin_entry: Any | None = None platform_id: str, plugin_entry: Any | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
override = _PLATFORM_OVERRIDES.get(platform_id, {}) override = _PLATFORM_OVERRIDES.get(platform_id, {})
if "env_vars" in override: env_vars = _merge_platform_env_vars(platform_id, override, plugin_entry)
env_vars: tuple[str, ...] = tuple(override["env_vars"])
elif plugin_entry is not None and plugin_entry.required_env:
env_vars = tuple(plugin_entry.required_env)
else:
prefix = platform_id.upper() + "_"
env_vars = tuple(k for k in OPTIONAL_ENV_VARS if k.startswith(prefix))
if "required_env" in override: if "required_env" in override:
required_env = tuple(override["required_env"]) required_env = tuple(override["required_env"])
@ -3077,6 +3171,329 @@ def _write_platform_enabled(platform_id: str, enabled: bool) -> None:
save_config(config) save_config(config)
_TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com"
_TELEGRAM_USER_ID_RE = re.compile(r"^\d+$")
@dataclass
class _TelegramOnboardingPairing:
poll_token: str
expires_at: str
expires_at_ts: float
bot_token: str | None = None
bot_username: str | None = None
owner_user_id: str | None = None
_telegram_onboarding_pairings: dict[str, _TelegramOnboardingPairing] = {}
_telegram_onboarding_lock = threading.RLock()
def _telegram_onboarding_base_url() -> str:
return (
os.getenv("TELEGRAM_ONBOARDING_URL", _TELEGRAM_ONBOARDING_DEFAULT_URL)
.strip()
.rstrip("/")
)
def _parse_expiry_ts(value: str) -> float:
try:
normalized = value.replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
except Exception:
return time.time() + 600
def _prune_telegram_onboarding_pairings() -> None:
now = time.time()
expired = [
pairing_id
for pairing_id, record in _telegram_onboarding_pairings.items()
if record.expires_at_ts <= now
]
for pairing_id in expired:
_telegram_onboarding_pairings.pop(pairing_id, None)
def _normalize_telegram_user_id(value: Any) -> str | None:
normalized = str(value or "").strip()
if _TELEGRAM_USER_ID_RE.fullmatch(normalized):
return normalized
return None
def _telegram_onboarding_error_message(error: str, fallback: str) -> str:
return {
"not_found": "Telegram pairing was not found. Start a new setup.",
"expired": "Telegram setup expired. Start a new setup.",
"claimed": "Telegram setup was already claimed. Start a new setup.",
"unauthorized": "Telegram setup service rejected this request.",
"telegram_manager_bot_token_not_configured": "Telegram setup service is not configured.",
"telegram_token_fetch_failed": "Telegram could not finish bot setup. Try again.",
}.get(error, fallback)
def _telegram_onboarding_request_sync(
method: str,
path: str,
*,
body: dict[str, Any] | None = None,
bearer_token: str | None = None,
) -> dict[str, Any]:
data = None
headers = {"Accept": "application/json"}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
if bearer_token:
headers["Authorization"] = f"Bearer {bearer_token}"
request = urllib.request.Request(
f"{_telegram_onboarding_base_url()}{path}",
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
payload = response.read()
except urllib.error.HTTPError as exc:
payload = exc.read()
try:
parsed = json.loads(payload.decode("utf-8"))
except Exception:
parsed = {}
error = str(parsed.get("error") or parsed.get("status") or "")
detail = _telegram_onboarding_error_message(
error,
"Telegram setup service returned an error.",
)
status_code = 404 if exc.code == 404 else 502
if error in {"expired", "claimed"}:
status_code = 410
raise HTTPException(status_code=status_code, detail=detail) from exc
except Exception as exc:
raise HTTPException(
status_code=502,
detail="Telegram setup service is unavailable. Try again shortly.",
) from exc
try:
parsed = json.loads(payload.decode("utf-8"))
except Exception as exc:
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an invalid response.",
) from exc
if not isinstance(parsed, dict):
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an invalid response.",
)
return parsed
async def _telegram_onboarding_request(
method: str,
path: str,
*,
body: dict[str, Any] | None = None,
bearer_token: str | None = None,
) -> dict[str, Any]:
return await asyncio.to_thread(
_telegram_onboarding_request_sync,
method,
path,
body=body,
bearer_token=bearer_token,
)
@app.post("/api/messaging/telegram/onboarding/start")
async def start_telegram_onboarding(body: TelegramOnboardingStart):
bot_name = (body.bot_name or "Hermes Agent").strip() or "Hermes Agent"
payload = await _telegram_onboarding_request(
"POST",
"/v1/telegram/pairings",
body={"bot_name": bot_name},
)
pairing_id = str(payload.get("pairing_id") or "").strip()
poll_token = str(payload.get("poll_token") or "").strip()
expires_at = str(payload.get("expires_at") or "").strip()
deep_link = str(payload.get("deep_link") or "").strip()
qr_payload = str(payload.get("qr_payload") or deep_link).strip()
suggested_username = str(payload.get("suggested_username") or "").strip()
if not pairing_id or not poll_token or not expires_at or not deep_link:
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an incomplete response.",
)
with _telegram_onboarding_lock:
_prune_telegram_onboarding_pairings()
_telegram_onboarding_pairings[pairing_id] = _TelegramOnboardingPairing(
poll_token=poll_token,
expires_at=expires_at,
expires_at_ts=_parse_expiry_ts(expires_at),
)
return {
"pairing_id": pairing_id,
"suggested_username": suggested_username,
"deep_link": deep_link,
"qr_payload": qr_payload,
"expires_at": expires_at,
}
@app.get("/api/messaging/telegram/onboarding/{pairing_id}")
async def get_telegram_onboarding_status(pairing_id: str):
with _telegram_onboarding_lock:
_prune_telegram_onboarding_pairings()
record = _telegram_onboarding_pairings.get(pairing_id)
if not record:
raise HTTPException(
status_code=404,
detail="Telegram setup session was not found. Start a new setup.",
)
if record.bot_token:
return {
"status": "ready",
"bot_username": record.bot_username,
"owner_user_id": record.owner_user_id,
"expires_at": record.expires_at,
}
poll_token = record.poll_token
payload = await _telegram_onboarding_request(
"GET",
f"/v1/telegram/pairings/{urllib.parse.quote(pairing_id, safe='')}",
bearer_token=poll_token,
)
status = str(payload.get("status") or "").strip()
if status == "waiting":
with _telegram_onboarding_lock:
current = _telegram_onboarding_pairings.get(pairing_id)
expires_at = current.expires_at if current else ""
return {"status": "waiting", "expires_at": expires_at}
if status == "ready":
bot_token = str(payload.get("token") or "").strip()
bot_username = str(payload.get("bot_username") or "").strip()
if not bot_token:
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an incomplete response.",
)
owner_user_id = _normalize_telegram_user_id(payload.get("owner_user_id"))
with _telegram_onboarding_lock:
record = _telegram_onboarding_pairings.get(pairing_id)
if not record:
raise HTTPException(
status_code=404,
detail="Telegram setup session was not found. Start a new setup.",
)
record.bot_token = bot_token
record.bot_username = bot_username or None
record.owner_user_id = owner_user_id
return {
"status": "ready",
"bot_username": record.bot_username,
"owner_user_id": record.owner_user_id,
"expires_at": record.expires_at,
}
if status in {"expired", "claimed"}:
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
raise HTTPException(
status_code=410,
detail=_telegram_onboarding_error_message(
status,
"Telegram setup is no longer available. Start a new setup.",
),
)
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an unknown status.",
)
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
async def apply_telegram_onboarding(
pairing_id: str, body: TelegramOnboardingApply
):
allowed_user_ids = []
seen = set()
for raw_id in body.allowed_user_ids:
normalized = _normalize_telegram_user_id(raw_id)
if not normalized:
raise HTTPException(
status_code=400,
detail="Allowed Telegram user IDs must be numeric.",
)
if normalized not in seen:
seen.add(normalized)
allowed_user_ids.append(normalized)
if not allowed_user_ids:
raise HTTPException(
status_code=400,
detail="Add at least one allowed Telegram user ID.",
)
with _telegram_onboarding_lock:
_prune_telegram_onboarding_pairings()
record = _telegram_onboarding_pairings.get(pairing_id)
if not record:
raise HTTPException(
status_code=404,
detail="Telegram setup session was not found. Start a new setup.",
)
bot_token = record.bot_token
bot_username = record.bot_username
if not bot_token:
raise HTTPException(
status_code=409,
detail="Telegram setup is not ready yet.",
)
try:
save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
_write_platform_enabled("telegram", True)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
_log.exception("Telegram onboarding apply failed")
raise HTTPException(
status_code=500,
detail="Failed to save Telegram setup.",
) from exc
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
return {
"ok": True,
"platform": "telegram",
"bot_username": bot_username,
"needs_restart": True,
}
@app.delete("/api/messaging/telegram/onboarding/{pairing_id}")
async def cancel_telegram_onboarding(pairing_id: str):
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
return {"ok": True}
@app.get("/api/messaging/platforms") @app.get("/api/messaging/platforms")
async def get_messaging_platforms(): async def get_messaging_platforms():
env_on_disk = load_env() env_on_disk = load_env()
@ -6771,6 +7188,7 @@ async def get_toolsets():
_get_effective_configurable_toolsets, _get_effective_configurable_toolsets,
_get_platform_tools, _get_platform_tools,
_toolset_has_keys, _toolset_has_keys,
gui_toolset_label,
) )
from toolsets import resolve_toolset from toolsets import resolve_toolset
@ -6788,7 +7206,9 @@ async def get_toolsets():
tools = [] tools = []
is_enabled = name in enabled_toolsets is_enabled = name in enabled_toolsets
result.append({ result.append({
"name": name, "label": label, "description": desc, "name": name,
"label": gui_toolset_label(label),
"description": desc,
"enabled": is_enabled, "enabled": is_enabled,
"available": is_enabled, "available": is_enabled,
"configured": _toolset_has_keys(name, config), "configured": _toolset_has_keys(name, config),
@ -7135,8 +7555,6 @@ async def get_models_analytics(days: int = 30):
# though uvicorn binds to 127.0.0.1. # though uvicorn binds to 127.0.0.1.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
import re
# PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native # 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 # Windows the import raises; catch and leave PtyBridge=None so the rest of
# the dashboard (sessions, jobs, metrics, config editor) still loads and the # the dashboard (sessions, jobs, metrics, config editor) still loads and the

View File

@ -615,17 +615,27 @@ class SessionDB:
) )
def _try_wal_checkpoint(self) -> None: def _try_wal_checkpoint(self) -> None:
"""Best-effort PASSIVE WAL checkpoint. Never blocks, never raises. """Best-effort TRUNCATE WAL checkpoint. Never raises.
Flushes committed WAL frames back into the main DB file for any Flushes committed WAL frames back into the main DB file and
frames that no other connection currently needs. Keeps the WAL truncates the WAL file to zero bytes. Keeps the WAL from
from growing unbounded when many processes hold persistent growing unbounded when many processes hold persistent
connections. connections.
PASSIVE checkpoint was previously used here, but it never
truncates the WAL file the file stays at its high-water
mark until an explicit TRUNCATE is called (which only
happened inside the infrequent vacuum()).
TRUNCATE may block writers briefly while checkpointing, but
_try_wal_checkpoint is called off the hot path (every 50
writes) and already runs under ``self._lock``, so the
additional hold time is negligible.
""" """
try: try:
with self._lock: with self._lock:
result = self._conn.execute( result = self._conn.execute(
"PRAGMA wal_checkpoint(PASSIVE)" "PRAGMA wal_checkpoint(TRUNCATE)"
).fetchone() ).fetchone()
if result and result[1] > 0: if result and result[1] > 0:
logger.debug( logger.debug(
@ -638,13 +648,13 @@ class SessionDB:
def close(self): def close(self):
"""Close the database connection. """Close the database connection.
Attempts a PASSIVE WAL checkpoint first so that exiting processes Attempts a TRUNCATE WAL checkpoint first so that exiting processes
help keep the WAL file from growing unbounded. help shrink the WAL file.
""" """
with self._lock: with self._lock:
if self._conn: if self._conn:
try: try:
self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)") self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception: except Exception:
pass pass
self._conn.close() self._conn.close()
@ -1124,6 +1134,24 @@ class SessionDB:
return None return None
return row["holder"] if isinstance(row, sqlite3.Row) else row[0] return row["holder"] if isinstance(row, sqlite3.Row) else row[0]
def update_session_meta(
self,
session_id: str,
model_config_json: str,
model: Optional[str] = None,
) -> None:
"""Update model_config and optionally model for an existing session.
Uses COALESCE so that passing model=None leaves the stored model
column unchanged. Routes through _execute_write for the standard
BEGIN IMMEDIATE + jitter-retry + lock guarantee.
"""
def _do(conn):
conn.execute(
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
(model_config_json, model, session_id),
)
self._execute_write(_do)
def update_system_prompt(self, session_id: str, system_prompt: str) -> None: def update_system_prompt(self, session_id: str, system_prompt: str) -> None:
"""Store the full assembled system prompt snapshot.""" """Store the full assembled system prompt snapshot."""
@ -1585,6 +1613,7 @@ class SessionDB:
order_by_last_active: bool = False, order_by_last_active: bool = False,
include_archived: bool = False, include_archived: bool = False,
archived_only: bool = False, archived_only: bool = False,
id_query: str = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""List sessions with preview (first user message) and last active timestamp. """List sessions with preview (first user message) and last active timestamp.
@ -1617,13 +1646,23 @@ class SessionDB:
params = [] params = []
if not include_children: if not include_children:
# Show root sessions and branch sessions (whose parent ended with # Show root sessions and branch sessions, while still hiding
# end_reason='branched' before the child was created), while still # sub-agent runs and compression continuations (which also carry a
# hiding sub-agent runs and compression continuations (which also # parent_session_id but were spawned while the parent was still
# carry a parent_session_id but were spawned while the parent was # live — i.e., started_at < parent.ended_at).
# still live — i.e., started_at < parent.ended_at). #
# Branch sessions are identified two ways, OR'd for robustness:
# 1. A stable ``_branched_from`` marker in model_config, written
# by /branch at creation time. This survives the parent being
# reopened and re-ended with a different end_reason (e.g.
# tui_shutdown overwriting 'branched'), which otherwise hides
# the branch — see issue #20856.
# 2. The legacy heuristic (parent ended with 'branched' before the
# child started), covering branch sessions created before the
# marker existed.
where_clauses.append( where_clauses.append(
"(s.parent_session_id IS NULL" "(s.parent_session_id IS NULL"
" OR json_extract(s.model_config, '$._branched_from') IS NOT NULL"
" OR EXISTS (SELECT 1 FROM sessions p" " OR EXISTS (SELECT 1 FROM sessions p"
" WHERE p.id = s.parent_session_id" " WHERE p.id = s.parent_session_id"
" AND p.end_reason = 'branched'" " AND p.end_reason = 'branched'"
@ -1646,6 +1685,16 @@ class SessionDB:
where_clauses.append("s.archived = 0") where_clauses.append("s.archived = 0")
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
# Optional session-id filter, pushed into SQL so callers (Desktop
# session-id search) don't have to fetch every row and filter in
# Python. ``id_query`` is matched as a case-insensitive substring
# against each surfaced row's id AND every id in its forward
# compression chain — so searching a compression *root* id or a *tip*
# id both resolve to the same projected conversation. Only used in the
# order_by_last_active path (which builds the chain CTE); other callers
# pass id_query=None.
id_needle = (id_query or "").strip().lower()
if order_by_last_active: if order_by_last_active:
# Compute effective_last_active by walking each surfaced session's # Compute effective_last_active by walking each surfaced session's
# compression-continuation chain forward in SQL and taking the MAX # compression-continuation chain forward in SQL and taking the MAX
@ -1658,6 +1707,28 @@ class SessionDB:
# compression-continuation edges using the same criteria as # compression-continuation edges using the same criteria as
# get_compression_tip (parent.end_reason='compression' AND # get_compression_tip (parent.end_reason='compression' AND
# child.started_at >= parent.ended_at). # child.started_at >= parent.ended_at).
outer_where = where_sql
id_params: List[Any] = []
if id_needle:
# Admit a surfaced row if its own id or any id in its forward
# compression chain matches the needle. LIKE with a leading
# wildcard can't use an index, but the chain membership and
# the small result set keep this bounded — far cheaper than
# fetching every session and scanning in Python.
id_clause = (
"EXISTS (SELECT 1 FROM chain cq"
" WHERE cq.root_id = s.id"
" AND LOWER(cq.cur_id) LIKE ? ESCAPE '\\')"
)
like_pattern = (
"%"
+ id_needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+ "%"
)
id_params = [like_pattern]
outer_where = (
f"{where_sql} AND {id_clause}" if where_sql else f"WHERE {id_clause}"
)
query = f""" query = f"""
WITH RECURSIVE chain(root_id, cur_id) AS ( WITH RECURSIVE chain(root_id, cur_id) AS (
SELECT s.id, s.id FROM sessions s {where_sql} SELECT s.id, s.id FROM sessions s {where_sql}
@ -1694,12 +1765,13 @@ class SessionDB:
COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active
FROM sessions s FROM sessions s
LEFT JOIN chain_max cm ON cm.root_id = s.id LEFT JOIN chain_max cm ON cm.root_id = s.id
{where_sql} {outer_where}
ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
""" """
# WHERE params apply twice (CTE seed + outer select). # WHERE params apply twice (CTE seed + outer select); the id filter
params = params + params + [limit, offset] # only applies to the outer select.
params = params + params + id_params + [limit, offset]
else: else:
query = f""" query = f"""
SELECT s.*, SELECT s.*,
@ -3027,6 +3099,53 @@ class SessionDB:
return matches return matches
def search_sessions_by_id(
self,
query: str,
limit: int = 20,
include_archived: bool = True,
) -> List[Dict[str, Any]]:
"""Search surfaced sessions by exact/prefix/substring session id.
Desktop search uses this alongside FTS message search so users can paste
a session id from logs, CLI output, or another Hermes surface and jump
straight to that conversation. Matching also checks ``_lineage_root_id``
for projected compression-chain tips, so an old root id still resolves to
the live continuation row.
"""
needle = (query or "").strip().lower()
if not needle or limit <= 0:
return []
# SQL-bounded: list_sessions_rich pushes the id LIKE filter into the
# query (matching the row's own id AND any id in its forward
# compression chain), so we only materialize matching rows instead of
# scanning every session. Fetch a small multiple of `limit` so the
# in-Python exact/prefix/substring ranking below has enough candidates
# to order, then truncate.
candidates = self.list_sessions_rich(
limit=max(limit * 4, limit),
offset=0,
include_archived=include_archived,
order_by_last_active=True,
id_query=needle,
)
def score(row: Dict[str, Any]) -> int:
ids = [str(row.get("id") or ""), str(row.get("_lineage_root_id") or "")]
normalized = [value.lower() for value in ids if value]
if any(value == needle for value in normalized):
return 0
if any(value.startswith(needle) for value in normalized):
return 1
return 2
ranked = sorted(
enumerate(candidates),
key=lambda item: (score(item[1]), item[0]),
)
return [row for _, row in ranked[:limit]]
def search_sessions( def search_sessions(
self, self,
source: str = None, source: str = None,

View File

@ -21,7 +21,7 @@ let
# Single npm deps fetch from the workspace root lockfile. # Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation. # All workspace packages share this derivation.
npmDepsHash = "sha256-2CoB0uUc8Pf9iNR0I1EzVqgL89B5sADnC9sxGah8ndU="; npmDepsHash = "sha256-T9UtpXgBCl/GywDZyrvG4a69RkV8oD6p1UOT7GPgAS0=";
npmDeps = pkgs.fetchNpmDeps { npmDeps = pkgs.fetchNpmDeps {
inherit src; inherit src;

221
package-lock.json generated
View File

@ -143,6 +143,9 @@
"vite": "^8.0.10", "vite": "^8.0.10",
"vitest": "^4.1.5", "vitest": "^4.1.5",
"wait-on": "^9.0.5" "wait-on": "^9.0.5"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
} }
}, },
"apps/desktop/node_modules/@nous-research/ui": { "apps/desktop/node_modules/@nous-research/ui": {
@ -8353,6 +8356,16 @@
"xmlbuilder": ">=11.0.1" "xmlbuilder": ">=11.0.1"
} }
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -8985,7 +8998,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -8995,7 +9007,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@ -9791,6 +9802,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001787", "version": "1.0.30001787",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
@ -10061,7 +10081,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@ -10074,7 +10093,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/colord": { "node_modules/colord": {
@ -10921,6 +10939,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@ -11083,6 +11110,12 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-compare": { "node_modules/dir-compare": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
@ -11542,7 +11575,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/end-of-stream": { "node_modules/end-of-stream": {
@ -12704,7 +12736,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@ -14133,7 +14164,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -16823,6 +16853,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-manager-detector": { "node_modules/package-manager-detector": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
@ -16905,7 +16944,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -17020,6 +17058,15 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/points-on-curve": { "node_modules/points-on-curve": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
@ -17262,6 +17309,141 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/quick-lru": { "node_modules/quick-lru": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
@ -17985,7 +18167,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -18000,6 +18181,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resedit": { "node_modules/resedit": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
@ -18485,6 +18672,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@ -18915,7 +19108,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@ -19042,7 +19234,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@ -20930,6 +21121,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.20", "version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
@ -21972,6 +22169,7 @@
"leva": "^0.10.1", "leva": "^0.10.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"qrcode": "^1.5.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router-dom": "^7.14.1", "react-router-dom": "^7.14.1",
@ -21982,6 +22180,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",

View File

@ -145,6 +145,33 @@ ALLOWED_CATEGORIES = {
} }
# Paths under $HERMES_HOME that must NEVER be deleted by quick(),
# regardless of what the stored category says. This is a defense-in-depth
# guard against stale tracked.json entries from before #34840.
_PROTECTED_CRON_PATHS: set[str] = set()
def _is_protected_cron_path(p: Path) -> bool:
"""Return True if *p* is a cron control-plane file/directory that must
never be deleted.
This only matches the directory itself and known control-plane files
(``jobs.json``, ``.tick.lock``) it does NOT blanket-protect
everything under ``cron/`` because ``cron/output/`` is disposable.
"""
# Lazily build the set once per process so HERMES_HOME is resolved
# exactly once.
if not _PROTECTED_CRON_PATHS:
hermes_home = get_hermes_home()
for parent in ("cron", "cronjobs"):
base = hermes_home / parent
_PROTECTED_CRON_PATHS.add(str(base))
_PROTECTED_CRON_PATHS.add(str(base / "jobs.json"))
_PROTECTED_CRON_PATHS.add(str(base / ".tick.lock"))
resolved = str(p.resolve())
return resolved in _PROTECTED_CRON_PATHS
def fmt_size(n: float) -> str: def fmt_size(n: float) -> str:
for unit in ("B", "KB", "MB", "GB", "TB"): for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024: if n < 1024:
@ -226,6 +253,14 @@ def dry_run() -> Tuple[List[Dict], List[Dict]]:
cat = item["category"] cat = item["category"]
size = item["size"] size = item["size"]
# Re-validate stale "cron-output" entries (fixes #37721).
if cat == "cron-output":
re_cat = guess_category(p)
if re_cat != "cron-output":
# Stale entry — would be skipped by quick(); omit from
# dry-run output too.
continue
if cat == "test": if cat == "test":
auto.append(item) auto.append(item)
elif cat == "temp" and age > 7: elif cat == "temp" and age > 7:
@ -269,6 +304,28 @@ def quick() -> Dict[str, Any]:
age = (now - datetime.fromisoformat(item["timestamp"])).days age = (now - datetime.fromisoformat(item["timestamp"])).days
# ---- stale-state migration (fixes #37721) ----
# Old tracked.json entries may carry a "cron-output" category for
# paths that are NOT under cron/output/ (e.g. cron/jobs.json).
# guess_category() was fixed in #34840, but existing entries are
# never re-validated. Re-classify here so stale entries for cron
# control-plane state are not deleted.
if cat == "cron-output":
re_cat = guess_category(p)
if re_cat != "cron-output":
_log(
f"SKIP stale cron-output entry: {p} "
f"(re-classified as {re_cat!r})"
)
# Drop the stale entry — it was misclassified.
continue
# Hard safety net: never delete cron control-plane state even if
# the category somehow slipped through re-validation above.
if _is_protected_cron_path(p):
_log(f"SKIP protected cron path: {p}")
continue
should_delete = ( should_delete = (
cat == "test" cat == "test"
or (cat == "temp" and age > 7) or (cat == "temp" and age > 7)

View File

@ -627,9 +627,9 @@ class OpenVikingMemoryProvider(MemoryProvider):
logger.warning("OpenViking session commit failed: %s", e) logger.warning("OpenViking session commit failed: %s", e)
def _build_memory_uri(self, subdir: str) -> str: def _build_memory_uri(self, subdir: str) -> str:
"""Build a viking:// memory URI under the configured user/subdir.""" """Build a viking:// memory URI under the configured user/agent/subdir."""
slug = uuid.uuid4().hex[:12] slug = uuid.uuid4().hex[:12]
return f"viking://user/{self._user}/memories/{subdir}/mem_{slug}.md" return f"viking://user/{self._user}/agent/{self._agent}/memories/{subdir}/mem_{slug}.md"
def on_memory_write( def on_memory_write(
self, self,

View File

@ -9,6 +9,7 @@ xiaomi = ProviderProfile(
env_vars=("XIAOMI_API_KEY",), env_vars=("XIAOMI_API_KEY",),
base_url="https://api.xiaomimimo.com/v1", base_url="https://api.xiaomimimo.com/v1",
supports_health_check=False, # /v1/models returns 401 even with valid key supports_health_check=False, # /v1/models returns 401 even with valid key
supports_vision=True, # mimo-v2-omni is vision-capable
) )
register_provider(xiaomi) register_provider(xiaomi)

View File

@ -1390,7 +1390,7 @@ class GoogleChatAdapter(BasePlatformAdapter):
if arg == "start": if arg == "start":
if not oauth_helper._client_secret_path().exists(): if not oauth_helper._client_secret_path().exists():
await _reply( await _reply(
"⚠️ No client credentials stored on the host. Send " "⚠️ No client credentials stored for this profile. Send "
"`/setup-files` (no args) for setup instructions." "`/setup-files` (no args) for setup instructions."
) )
return True return True

View File

@ -50,10 +50,8 @@ Token storage layout
``${HERMES_HOME}/google_chat_user_oauth_pending/<sanitized_email>.json`` ``${HERMES_HOME}/google_chat_user_oauth_pending/<sanitized_email>.json``
- Legacy pending state: - Legacy pending state:
``${HERMES_HOME}/google_chat_user_oauth_pending.json`` ``${HERMES_HOME}/google_chat_user_oauth_pending.json``
- Shared OAuth client (one per host, anchored at the default Hermes root so - OAuth client secret (profile-scoped each profile registers its own):
every profile sees it; a profile-local copy under ``${HERMES_HOME}`` wins ``${HERMES_HOME}/google_chat_user_client_secret.json``
when present):
``<default-root>/google_chat_user_client_secret.json`` (default ``~/.hermes``)
""" """
from __future__ import annotations from __future__ import annotations
@ -77,11 +75,7 @@ logger = logging.getLogger("gateway.platforms.google_chat_user_oauth")
# Use the project's HERMES_HOME helper so the token follows the user's # Use the project's HERMES_HOME helper so the token follows the user's
# profile (e.g. tests can override via HERMES_HOME=/tmp/...). # profile (e.g. tests can override via HERMES_HOME=/tmp/...).
try: try:
from hermes_constants import ( from hermes_constants import display_hermes_home, get_hermes_home
display_hermes_home,
get_default_hermes_root,
get_hermes_home,
)
except (ModuleNotFoundError, ImportError): except (ModuleNotFoundError, ImportError):
# Fallback for environments where hermes_constants isn't importable # Fallback for environments where hermes_constants isn't importable
# (mirrors the same fallback used by the google-workspace skill's # (mirrors the same fallback used by the google-workspace skill's
@ -90,24 +84,6 @@ except (ModuleNotFoundError, ImportError):
val = os.environ.get("HERMES_HOME", "").strip() val = os.environ.get("HERMES_HOME", "").strip()
return Path(val) if val else Path.home() / ".hermes" return Path(val) if val else Path.home() / ".hermes"
def get_default_hermes_root() -> Path:
# Mirror hermes_constants.get_default_hermes_root(): resolve the
# profile root so host-wide files (the shared client secret) are
# found regardless of which profile is active.
native_home = Path.home() / ".hermes"
env_home = os.environ.get("HERMES_HOME", "").strip()
if not env_home:
return native_home
env_path = Path(env_home)
try:
env_path.resolve().relative_to(native_home.resolve())
return native_home
except ValueError:
pass
if env_path.parent.name == "profiles":
return env_path.parent.parent
return env_path
def display_hermes_home() -> str: def display_hermes_home() -> str:
home = get_hermes_home() home = get_hermes_home()
try: try:
@ -164,24 +140,7 @@ def _token_path(email: Optional[str] = None) -> Path:
def _client_secret_path() -> Path: def _client_secret_path() -> Path:
"""Path to the shared OAuth client secret (one per host). return _hermes_home() / "google_chat_user_client_secret.json"
The client secret identifies the OAuth *app*, not a user or a profile,
so it is anchored at the default Hermes root (``~/.hermes`` or the
Docker root) rather than the active profile's ``HERMES_HOME``. That way
the one-time ``--client-secret`` host setup is visible to gateways
running under any named profile, exactly as the docs describe ("one
file per host is enough no matter how many users authorize later").
A profile-local secret (``$HERMES_HOME/google_chat_user_client_secret.json``)
still takes precedence when present, for installs that seeded one under
the previous profile-scoped behavior or that deliberately run a separate
OAuth app per profile.
"""
profile_local = _hermes_home() / "google_chat_user_client_secret.json"
if profile_local.exists():
return profile_local
return get_default_hermes_root() / "google_chat_user_client_secret.json"
def _pending_auth_path(email: Optional[str] = None) -> Path: def _pending_auth_path(email: Optional[str] = None) -> Path:

View File

@ -56,6 +56,15 @@ class ProviderProfile:
auth_type: str = "api_key" # api_key|oauth_device_code|oauth_external|copilot|aws_sdk auth_type: str = "api_key" # api_key|oauth_device_code|oauth_external|copilot|aws_sdk
supports_health_check: bool = True # False → doctor skips /models probe for this provider supports_health_check: bool = True # False → doctor skips /models probe for this provider
# ── Vision support ────────────────────────────────────────
# True when the provider's API accepts image content inside
# tool-result messages natively. Set on providers that expose
# multimodal models via tool results (Anthropic Messages API,
# OpenAI Chat Completions, Gemini, Xiaomi, MiniMax, etc.).
# Falls back to model-catalog lookup when False and the provider
# has no registered profile.
supports_vision: bool = False
# ── Model catalog ───────────────────────────────────────── # ── Model catalog ─────────────────────────────────────────
# fallback_models: curated list shown in /model picker when live fetch fails. # fallback_models: curated list shown in /model picker when live fetch fails.
# Only agentic models that support tool calling should appear here. # Only agentic models that support tool calling should appear here.

View File

@ -61,6 +61,16 @@ dependencies = [
"prompt_toolkit==3.0.52", "prompt_toolkit==3.0.52",
# Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter). # Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).
"croniter==6.0.0", "croniter==6.0.0",
# Markdown -> HTML conversion for rich message delivery (Matrix
# `formatted_body`, and the `send_message` tool's HTML path). Now on the
# DEFAULT delivery path, not matrix-specific: without it both
# gateway/platforms/matrix.py and tools/send_message_tool.py silently fall
# back to plain text, so cron/agent deliveries render raw `##`/`**`/tables
# in clients like Element (see #32486). Pure-Python py3-none-any wheel
# (~108KB, no compiled extensions, no platform constraints), so unlike the
# matrix extra's `mautrix`/`python-olm` it's safe to ship everywhere — keeps
# it out of the lazy-install path that exists only for the heavy matrix deps.
"Markdown==3.10.2",
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
"PyJWT[crypto]==2.12.1", # CVE-2026-32597 "PyJWT[crypto]==2.12.1", # CVE-2026-32597
# Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo`` # Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo``
@ -104,7 +114,7 @@ dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-time
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.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
cron = [] # croniter is now a core dependency; this extra kept for back-compat 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.3"]
matrix = ["mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"] 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 callback-mode adapter — parses untrusted XML POST bodies from
# WeCom-controlled callback endpoints, so we use defusedxml (drop-in # WeCom-controlled callback endpoints, so we use defusedxml (drop-in
# replacement for stdlib xml.etree.ElementTree) to block billion-laughs # replacement for stdlib xml.etree.ElementTree) to block billion-laughs
@ -230,7 +240,6 @@ all = [
# where the user is expected to have a toolchain available. # where the user is expected to have a toolchain available.
"hermes-agent[cron]", "hermes-agent[cron]",
"hermes-agent[cli]", "hermes-agent[cli]",
"hermes-agent[dev]",
"hermes-agent[pty]", "hermes-agent[pty]",
"hermes-agent[mcp]", "hermes-agent[mcp]",
"hermes-agent[homeassistant]", "hermes-agent[homeassistant]",

View File

@ -8,7 +8,7 @@ REM Usage:
REM curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd REM curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd
REM REM
REM Or if you're already in PowerShell, use the direct command instead: REM Or if you're already in PowerShell, use the direct command instead:
REM iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) REM iex (irm https://hermes-agent.nousresearch.com/install.ps1)
REM ============================================================================ REM ============================================================================
echo. echo.
@ -16,12 +16,12 @@ echo Hermes Agent Installer
echo Launching PowerShell installer... echo Launching PowerShell installer...
echo. echo.
powershell -ExecutionPolicy ByPass -NoProfile -Command "iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)" powershell -ExecutionPolicy ByPass -NoProfile -Command "iex (irm https://hermes-agent.nousresearch.com/install.ps1)"
if %ERRORLEVEL% NEQ 0 ( if %ERRORLEVEL% NEQ 0 (
echo. echo.
echo Installation failed. Please try running PowerShell directly: echo Installation failed. Please try running PowerShell directly:
echo powershell -ExecutionPolicy ByPass -c "iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)" echo powershell -ExecutionPolicy ByPass -c "iex (irm https://hermes-agent.nousresearch.com/install.ps1)"
echo. echo.
pause pause
exit /b 1 exit /b 1

View File

@ -5,7 +5,7 @@
# Uses uv for fast Python provisioning and package management. # Uses uv for fast Python provisioning and package management.
# #
# Usage: # Usage:
# iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) # iex (irm https://hermes-agent.nousresearch.com/install.ps1)
# #
# Or download and run with options: # Or download and run with options:
# .\install.ps1 -NoVenv -SkipSetup # .\install.ps1 -NoVenv -SkipSetup
@ -1138,7 +1138,7 @@ function Install-Repository {
Write-Info "Trying SSH clone..." Write-Info "Trying SSH clone..."
$env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5" $env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5"
try { try {
git -c windows.appendAtomically=false clone --branch $Branch $RepoUrlSsh $InstallDir git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlSsh $InstallDir
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
} catch { } } catch { }
$env:GIT_SSH_COMMAND = $null $env:GIT_SSH_COMMAND = $null
@ -1147,7 +1147,7 @@ function Install-Repository {
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue } if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
Write-Info "SSH failed, trying HTTPS..." Write-Info "SSH failed, trying HTTPS..."
try { try {
git -c windows.appendAtomically=false clone --branch $Branch $RepoUrlHttps $InstallDir git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlHttps $InstallDir
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
} catch { } } catch { }
} }
@ -2844,7 +2844,7 @@ try {
Write-Err "Installation failed: $_" Write-Err "Installation failed: $_"
Write-Host "" Write-Host ""
Write-Info "If the error is unclear, try downloading and running the script directly:" Write-Info "If the error is unclear, try downloading and running the script directly:"
Write-Host " Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1' -OutFile install.ps1" -ForegroundColor Yellow Write-Host " Invoke-WebRequest -Uri 'https://hermes-agent.nousresearch.com/install.ps1' -OutFile install.ps1" -ForegroundColor Yellow
Write-Host " .\install.ps1" -ForegroundColor Yellow Write-Host " .\install.ps1" -ForegroundColor Yellow
Write-Host "" Write-Host ""
} }

View File

@ -6,7 +6,7 @@
# Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux. # Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux.
# #
# Usage: # Usage:
# curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash # curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
# #
# Or with options: # Or with options:
# curl -fsSL ... | bash -s -- --no-venv --skip-setup # curl -fsSL ... | bash -s -- --no-venv --skip-setup
@ -451,7 +451,7 @@ detect_os() {
OS="windows" OS="windows"
DISTRO="windows" DISTRO="windows"
log_error "Windows detected. Please use the PowerShell installer:" log_error "Windows detected. Please use the PowerShell installer:"
log_info " iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)" log_info " iex (irm https://hermes-agent.nousresearch.com/install.ps1)"
exit 1 exit 1
;; ;;
*) *)
@ -1126,12 +1126,12 @@ clone_repo() {
# so SSH fails fast instead of hanging when no key is configured. # so SSH fails fast instead of hanging when no key is configured.
log_info "Trying SSH clone..." log_info "Trying SSH clone..."
if GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=5" \ if GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=5" \
git clone --branch "$BRANCH" "$REPO_URL_SSH" "$INSTALL_DIR" 2>/dev/null; then git clone --depth 1 --branch "$BRANCH" "$REPO_URL_SSH" "$INSTALL_DIR" 2>/dev/null; then
log_success "Cloned via SSH" log_success "Cloned via SSH"
else else
rm -rf "$INSTALL_DIR" 2>/dev/null # Clean up partial SSH clone rm -rf "$INSTALL_DIR" 2>/dev/null # Clean up partial SSH clone
log_info "SSH failed, trying HTTPS..." log_info "SSH failed, trying HTTPS..."
if git clone --branch "$BRANCH" "$REPO_URL_HTTPS" "$INSTALL_DIR"; then if git clone --depth 1 --branch "$BRANCH" "$REPO_URL_HTTPS" "$INSTALL_DIR"; then
log_success "Cloned via HTTPS" log_success "Cloned via HTTPS"
else else
log_error "Failed to clone repository" log_error "Failed to clone repository"

View File

@ -45,8 +45,11 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides # Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = { AUTHOR_MAP = {
"dirtyren@users.noreply.github.com": "dirtyren",
"zhaolei.vc@bytedance.com": "zhaoleibd", "zhaolei.vc@bytedance.com": "zhaoleibd",
"jeffrobodie@gmail.com": "jeffrobodie-glitch",
"kyssta-exe@users.noreply.github.com": "kyssta-exe", "kyssta-exe@users.noreply.github.com": "kyssta-exe",
"ali.zakaee.1997@gmail.com": "ITheEqualizer",
"copii.list@gmail.com": "stremtec", "copii.list@gmail.com": "stremtec",
"solaiagent@gmail.com": "solaitken", "solaiagent@gmail.com": "solaitken",
"cryptoworlldz@gmail.com": "worlldz", "cryptoworlldz@gmail.com": "worlldz",
@ -63,6 +66,8 @@ AUTHOR_MAP = {
"david.gutowsky@gmail.com": "davidgut1982", "david.gutowsky@gmail.com": "davidgut1982",
"drpelagik@gmail.com": "SeaXen", "drpelagik@gmail.com": "SeaXen",
"lengr@users.noreply.github.com": "LengR", "lengr@users.noreply.github.com": "LengR",
"Kewe63@users.noreply.github.com": "Kewe63",
"kewe.3217@gmail.com": "Kewe63",
"17255546+CharZhou@users.noreply.github.com": "CharZhou", "17255546+CharZhou@users.noreply.github.com": "CharZhou",
"metalclaudbot@gmail.com": "HashClawAI", "metalclaudbot@gmail.com": "HashClawAI",
"tonybear55665566@gmail.com": "TonyPepeBear", "tonybear55665566@gmail.com": "TonyPepeBear",

View File

@ -35,7 +35,7 @@ People use Hermes for software development, research, system administration, dat
```bash ```bash
# Install # Install
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
# Interactive chat (default) # Interactive chat (default)
hermes hermes

View File

@ -0,0 +1,201 @@
"""Tests for the update_session_meta fix.
Verifies that:
1. SessionDB.update_session_meta() exists and works correctly via the
public _execute_write path (not db._lock / db._conn directly).
2. session.py _persist() no longer touches db._lock or db._conn.
3. update_session_meta updates the correct columns atomically.
"""
import ast
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch, call
import pytest
from hermes_state import SessionDB
from acp_adapter.session import SessionManager
def _tmp_db(tmp_path):
return SessionDB(db_path=tmp_path / "state.db")
def _mock_agent():
return MagicMock(name="MockAIAgent")
# ---------------------------------------------------------------------------
# hermes_state.SessionDB.update_session_meta — unit tests
# ---------------------------------------------------------------------------
class TestUpdateSessionMeta:
"""Direct unit tests for the new public method."""
def test_method_exists(self, tmp_path):
db = _tmp_db(tmp_path)
assert hasattr(db, "update_session_meta"), (
"SessionDB must have update_session_meta() public method"
)
assert callable(db.update_session_meta)
def test_updates_model_config(self, tmp_path):
db = _tmp_db(tmp_path)
db.create_session("s1", source="acp", model="gpt-4")
new_meta = json.dumps({"cwd": "/new/path", "provider": "openai"})
db.update_session_meta("s1", new_meta, model=None)
row = db.get_session("s1")
stored = json.loads(row["model_config"])
assert stored["cwd"] == "/new/path"
assert stored["provider"] == "openai"
def test_updates_model_when_provided(self, tmp_path):
db = _tmp_db(tmp_path)
db.create_session("s2", source="acp", model="gpt-3.5")
db.update_session_meta("s2", json.dumps({"cwd": "."}), model="gpt-4o")
row = db.get_session("s2")
assert row["model"] == "gpt-4o"
def test_preserves_existing_model_when_none(self, tmp_path):
"""Passing model=None must leave the stored model unchanged (COALESCE)."""
db = _tmp_db(tmp_path)
db.create_session("s3", source="acp", model="claude-3")
db.update_session_meta("s3", json.dumps({"cwd": "."}), model=None)
row = db.get_session("s3")
assert row["model"] == "claude-3"
def test_uses_execute_write_not_private_api(self, tmp_path):
"""update_session_meta must route through _execute_write, not _conn directly."""
db = _tmp_db(tmp_path)
db.create_session("s4", source="acp")
call_count = [0]
original = db._execute_write
def patched(fn):
call_count[0] += 1
return original(fn)
db._execute_write = patched
db.update_session_meta("s4", json.dumps({"cwd": "."}), model="m")
assert call_count[0] >= 1, (
"update_session_meta must call _execute_write at least once"
)
def test_noop_on_nonexistent_session(self, tmp_path):
"""Updating a non-existent session must not raise."""
db = _tmp_db(tmp_path)
db.update_session_meta("ghost", json.dumps({"cwd": "."}), model=None)
# ---------------------------------------------------------------------------
# AST check: session.py must not access db._lock or db._conn
# ---------------------------------------------------------------------------
class TestNoPrviateDBAccess:
"""_persist() in session.py must not access db._lock or db._conn."""
def test_no_db_private_lock_access(self):
with open("acp_adapter/session.py", encoding="utf-8") as f:
source = f.read()
tree = ast.parse(source)
violations = []
for node in ast.walk(tree):
# Looking for: db._lock or db._conn
if isinstance(node, ast.Attribute):
if isinstance(node.value, ast.Name) and node.value.id == "db":
if node.attr in ("_lock", "_conn"):
violations.append(
f"db.{node.attr} at line {node.lineno}"
)
assert violations == [], (
"session.py accesses private SessionDB internals: "
+ ", ".join(violations)
+ " — use db.update_session_meta() instead"
)
def test_persist_calls_update_session_meta(self):
"""AST check: _persist must call db.update_session_meta()."""
with open("acp_adapter/session.py", encoding="utf-8") as f:
tree = ast.parse(f.read())
found = False
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "_persist":
for child in ast.walk(node):
if isinstance(child, ast.Call):
func = child.func
if isinstance(func, ast.Attribute):
if func.attr == "update_session_meta":
found = True
break
break
assert found, (
"_persist() must call db.update_session_meta() "
"instead of db._conn.execute() directly"
)
# ---------------------------------------------------------------------------
# Integration: _persist round-trip via SessionManager
# ---------------------------------------------------------------------------
class TestPersistRoundTrip:
"""End-to-end: save a session and verify DB state is correct."""
def test_cwd_persisted_via_update_session_meta(self, tmp_path):
db = _tmp_db(tmp_path)
manager = SessionManager(agent_factory=_mock_agent, db=db)
state = manager.create_session(cwd="/original")
assert db.get_session(state.session_id) is not None
# Simulate cwd change and save
state.cwd = "/updated"
manager.save_session(state.session_id)
row = db.get_session(state.session_id)
mc = json.loads(row["model_config"])
assert mc["cwd"] == "/updated"
def test_model_persisted_via_update_session_meta(self, tmp_path):
db = _tmp_db(tmp_path)
manager = SessionManager(agent_factory=_mock_agent, db=db)
state = manager.create_session()
state.model = "new-model-xyz"
manager.save_session(state.session_id)
row = db.get_session(state.session_id)
assert row["model"] == "new-model-xyz"
def test_existing_model_not_cleared_on_save(self, tmp_path):
"""If state.model is empty, the DB model column must not be overwritten."""
db = _tmp_db(tmp_path)
manager = SessionManager(agent_factory=_mock_agent, db=db)
state = manager.create_session()
# Manually set a model in DB
db.update_session_meta(state.session_id, json.dumps({"cwd": "."}), model="stored-model")
# Now save with empty model
state.model = ""
manager.save_session(state.session_id)
row = db.get_session(state.session_id)
assert row["model"] == "stored-model", (
"COALESCE must preserve the existing model when new value is NULL"
)

View File

@ -98,6 +98,16 @@ class TestIsLocalEndpoint:
def test_container_dns_names(self, url): def test_container_dns_names(self, url):
assert is_local_endpoint(url) is True assert is_local_endpoint(url) is True
@pytest.mark.parametrize("url", [
"http://ollama:11434",
"http://litellm:4000/v1",
"http://hermes-litellm:8080",
"http://vllm:8000",
])
def test_unqualified_docker_hostnames(self, url):
"""Unqualified hostnames (no dots) are local — Docker Compose, /etc/hosts, etc."""
assert is_local_endpoint(url) is True
@pytest.mark.parametrize("url", [ @pytest.mark.parametrize("url", [
"https://api.openai.com", "https://api.openai.com",
"https://openrouter.ai/api", "https://openrouter.ai/api",

View File

@ -16,7 +16,6 @@ import json
import os import os
import sys import sys
import types import types
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -1651,48 +1650,6 @@ class TestUserOAuthHelper:
}, },
) )
def test_client_secret_is_shared_across_profiles(self, tmp_path, monkeypatch):
"""The OAuth client secret is host-wide infra: a secret seeded at the
default root by the documented one-time `--client-secret` host step
must be visible to a gateway running under a named profile.
Regression: `_client_secret_path()` used to scope to the active
HERMES_HOME, so a profile gateway reported 'No client credentials
stored on the host' even after the host setup had been run.
"""
root = tmp_path / ".hermes"
profile_home = root / "profiles" / "bot1"
profile_home.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Seed the secret at the default root, as the host setup does.
secret = root / "google_chat_user_client_secret.json"
secret.write_text("{}", encoding="utf-8")
# Resolve from inside a named profile.
monkeypatch.setenv("HERMES_HOME", str(profile_home))
from plugins.platforms.google_chat.oauth import _client_secret_path
assert _client_secret_path() == secret
assert _client_secret_path().exists()
def test_profile_local_client_secret_takes_precedence(self, tmp_path, monkeypatch):
"""A profile-local secret (separate OAuth app per bot, or a legacy
profile-scoped seed) overrides the host-wide default when present."""
root = tmp_path / ".hermes"
profile_home = root / "profiles" / "bot1"
profile_home.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(profile_home))
(root / "google_chat_user_client_secret.json").write_text(
"{}", encoding="utf-8"
)
profile_secret = profile_home / "google_chat_user_client_secret.json"
profile_secret.write_text("{}", encoding="utf-8")
from plugins.platforms.google_chat.oauth import _client_secret_path
assert _client_secret_path() == profile_secret
def test_store_client_secret_writes_private_json(self, tmp_path, monkeypatch): def test_store_client_secret_writes_private_json(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("HERMES_HOME", str(tmp_path))
src = tmp_path / "client_secret.json" src = tmp_path / "client_secret.json"

View File

@ -954,6 +954,120 @@ class TestMediaDeliveryDefaultMode:
out = BasePlatformAdapter.filter_local_delivery_paths([str(notes)]) out = BasePlatformAdapter.filter_local_delivery_paths([str(notes)])
assert out == [str(notes.resolve())] assert out == [str(notes.resolve())]
def test_root_home_deliverable_is_accepted(self, tmp_path, monkeypatch):
"""The motivating bug (#38106): a root-run gateway has ``$HOME=/root``,
which is on the system-prefix denylist. A plain deliverable the agent
produced in its working dir (``/root/work/proposal.docx``) must still
deliver the home itself is not a credential location.
"""
self._patch_roots(monkeypatch)
fake_home = tmp_path / "root"
workdir = fake_home / "work"
workdir.mkdir(parents=True)
doc = workdir / "proposal.docx"
doc.write_bytes(b"PK\x03\x04")
monkeypatch.setenv("HOME", str(fake_home))
# $HOME is itself on the denied-prefix list, mirroring /root.
monkeypatch.setattr(
"gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES",
(str(fake_home),),
)
assert (
BasePlatformAdapter.validate_media_delivery_path(str(doc))
== str(doc.resolve())
)
def test_root_home_credential_subdir_still_blocked(self, tmp_path, monkeypatch):
"""The $HOME exception must NOT un-block credential sub-dirs inside
home. ``/root/.ssh/id_rsa`` stays denied because ``~/.ssh`` is a
separate, more-specific denylist entry even when $HOME is itself a
denied prefix.
"""
self._patch_roots(monkeypatch)
fake_home = tmp_path / "root"
ssh_dir = fake_home / ".ssh"
ssh_dir.mkdir(parents=True)
key = ssh_dir / "id_rsa"
key.write_bytes(b"-----BEGIN OPENSSH PRIVATE KEY-----")
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setattr(
"gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES",
(str(fake_home),),
)
assert BasePlatformAdapter.validate_media_delivery_path(str(key)) is None
def test_root_home_hermes_env_still_blocked(self, tmp_path, monkeypatch):
"""``~/.hermes/.env`` stays blocked under the $HOME exception — it is a
more-specific denied path, not reachable just because home is allowed.
"""
self._patch_roots(monkeypatch)
fake_home = tmp_path / "root"
hermes_dir = fake_home / ".hermes"
hermes_dir.mkdir(parents=True)
env_file = hermes_dir / ".env"
env_file.write_text("OPENROUTER_API_KEY=sk-...")
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setattr(
"gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES",
(str(fake_home),),
)
monkeypatch.setattr("gateway.platforms.base._HERMES_HOME", hermes_dir)
assert BasePlatformAdapter.validate_media_delivery_path(str(env_file)) is None
def test_other_users_home_still_blocked_for_nonroot(self, tmp_path, monkeypatch):
"""The exception only un-blocks the *running user's own* home. A
non-root gateway ($HOME=/home/me) must not deliver another user's home
(``/root/...``) that prefix stays denied because it isn't $HOME.
"""
self._patch_roots(monkeypatch)
my_home = tmp_path / "home" / "me"
my_home.mkdir(parents=True)
other_home = tmp_path / "root"
other_home.mkdir()
other_file = other_home / "secret.docx"
other_file.write_bytes(b"PK\x03\x04")
monkeypatch.setenv("HOME", str(my_home))
# Both my home and the other home are denied prefixes; only my home is
# the running user's $HOME, so the other home must stay blocked.
monkeypatch.setattr(
"gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES",
(str(my_home), str(other_home)),
)
assert (
BasePlatformAdapter.validate_media_delivery_path(str(other_file)) is None
)
def test_root_home_workdir_symlink_to_credential_blocked(self, tmp_path, monkeypatch):
"""A symlink in the workdir pointing at a credential is rejected on its
resolved target, even under the $HOME exception.
"""
self._patch_roots(monkeypatch)
fake_home = tmp_path / "root"
ssh_dir = fake_home / ".ssh"
ssh_dir.mkdir(parents=True)
key = ssh_dir / "id_rsa"
key.write_bytes(b"-----BEGIN OPENSSH PRIVATE KEY-----")
workdir = fake_home / "work"
workdir.mkdir()
link = workdir / "innocent.pdf"
link.symlink_to(key)
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setattr(
"gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES",
(str(fake_home),),
)
assert BasePlatformAdapter.validate_media_delivery_path(str(link)) is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# should_send_media_as_audio # should_send_media_as_audio

View File

@ -228,3 +228,54 @@ class TestSessionDbCloseOnShutdown:
flaky_db.close.assert_called_once() flaky_db.close.assert_called_once()
healthy_db.close.assert_called_once() healthy_db.close.assert_called_once()
class TestSessionResetZombieRace:
"""Regression for #28686 — a session_reset racing the in-flight run's
guarded release must not leave a dead agent locking the slot forever.
"""
def test_generation_guard_blocks_then_unconditional_release_evicts(self):
runner = _make_runner()
runner._session_run_generation = {}
key = "agent:main:telegram:private:1"
gen_n = runner._begin_session_run_generation(key)
dead_agent = MagicMock()
runner._running_agents[key] = dead_agent
runner._running_agents_ts[key] = 1.0
runner._busy_ack_ts[key] = 1.0
# session_reset bumps the generation while gen-N is still in flight.
runner._invalidate_session_run_generation(key, reason="session_reset")
# gen-N's own guarded release is correctly blocked — slot would be a
# zombie if nothing else cleared it (the pre-fix behaviour).
assert runner._release_running_agent_state(key, run_generation=gen_n) is False
assert runner._running_agents.get(key) is dead_agent
# The fix: unconditional release (no run_generation) always clears it.
assert runner._release_running_agent_state(key) is True
assert key not in runner._running_agents
assert key not in runner._running_agents_ts
assert key not in runner._busy_ack_ts
def test_normal_completion_is_not_evicted_by_outer_release(self):
"""Guarded release with the current generation succeeds; the outer
unconditional release that follows is a harmless no-op.
"""
runner = _make_runner()
runner._session_run_generation = {}
key = "agent:main:telegram:private:2"
gen = runner._begin_session_run_generation(key)
runner._running_agents[key] = MagicMock()
runner._running_agents_ts[key] = 1.0
runner._busy_ack_ts[key] = 1.0
assert runner._release_running_agent_state(key, run_generation=gen) is True
assert key not in runner._running_agents
# Outer finally runs the unconditional release after — nothing stranded.
assert runner._release_running_agent_state(key) is True
assert key not in runner._running_agents_ts
assert key not in runner._busy_ack_ts

View File

@ -17,18 +17,12 @@ from pathlib import Path
import pytest import pytest
def _make_auth_store( def _make_auth_store(pool: dict | None = None, providers: dict | None = None) -> dict:
pool: dict | None = None,
providers: dict | None = None,
active_provider: str | None = None,
) -> dict:
store: dict = {"version": 1} store: dict = {"version": 1}
if pool is not None: if pool is not None:
store["credential_pool"] = pool store["credential_pool"] = pool
if providers is not None: if providers is not None:
store["providers"] = providers store["providers"] = providers
if active_provider is not None:
store["active_provider"] = active_provider
return store return store
@ -456,101 +450,3 @@ def test_write_credential_pool_targets_profile_not_global(profile_env):
# Subsequent read returns profile (shadows global). # Subsequent read returns profile (shadows global).
assert [e["id"] for e in read_credential_pool("openrouter")] == ["prof-new"] assert [e["id"] for e in read_credential_pool("openrouter")] == ["prof-new"]
# ---------------------------------------------------------------------------
# get_active_provider — global active_provider fallback (issue #18594 follow-up)
#
# The per-provider state/pool fallbacks let a profile *read* a provider that
# was only authenticated at the global root, but ``resolve_provider()`` picks
# the ``auto`` provider from ``active_provider`` — which only ever read the
# profile store. A named profile running ``model.provider: auto`` could see
# the global Nous login (``get_provider_auth_state('nous')`` succeeds) yet
# still fail to select it. These pin the active_provider shadowing so the
# selection mirrors the state/pool fallbacks: profile wins when present, fall
# back to global when the profile never chose its own provider.
# ---------------------------------------------------------------------------
def test_active_provider_falls_back_to_global(profile_env):
"""An empty profile inherits the global-root active_provider selection."""
from hermes_cli.auth import get_active_provider
_write(profile_env["global"] / "auth.json", _make_auth_store(
providers={"nous": {"access_token": "nous-global"}},
active_provider="nous",
))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={}))
assert get_active_provider() == "nous"
def test_active_provider_profile_wins_over_global(profile_env):
"""A profile that selected its own provider shadows the global selection."""
from hermes_cli.auth import get_active_provider
_write(profile_env["global"] / "auth.json", _make_auth_store(
providers={"nous": {"access_token": "nous-global"}},
active_provider="nous",
))
_write(profile_env["profile"] / "auth.json", _make_auth_store(
providers={"anthropic": {"access_token": "ant-profile"}},
active_provider="anthropic",
))
assert get_active_provider() == "anthropic"
def test_active_provider_none_when_neither_has_it(profile_env):
"""No selection anywhere stays None — the fallback must not invent one."""
from hermes_cli.auth import get_active_provider
_write(profile_env["global"] / "auth.json", _make_auth_store(providers={}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={}))
assert get_active_provider() is None
def test_active_provider_classic_mode_reads_profile(tmp_path, monkeypatch):
"""In classic mode there is no global to fall back to; behavior is unchanged."""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: fake_home)
hermes_home = tmp_path / "classic"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_write(hermes_home / "auth.json", _make_auth_store(
providers={"nous": {"access_token": "classic-token"}},
active_provider="nous",
))
from hermes_cli.auth import get_active_provider
assert get_active_provider() == "nous"
def test_resolve_provider_uses_global_active_provider(profile_env, monkeypatch):
"""resolve_provider('auto') honors the global-root active_provider.
This is the user-visible contract: a named profile with no provider entry
of its own, started with ``model.provider: auto`` while a valid login
exists at the global root, resolves that provider instead of raising
``No inference provider configured``. ``get_auth_status`` is stubbed so the
login check stays offline (no Nous token refresh / network).
"""
import hermes_cli.auth as auth
_write(profile_env["global"] / "auth.json", _make_auth_store(
providers={"nous": {"access_token": "nous-global"}},
active_provider="nous",
))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={}))
monkeypatch.setattr(
auth,
"get_auth_status",
lambda provider=None: {"logged_in": True, "provider": provider},
)
assert auth.resolve_provider("auto") == "nous"

View File

@ -8,6 +8,7 @@ from types import SimpleNamespace
import pytest import pytest
pwd = pytest.importorskip("pwd") pwd = pytest.importorskip("pwd")
grp = pytest.importorskip("grp")
import hermes_cli.gateway as gateway_cli import hermes_cli.gateway as gateway_cli
from gateway import status from gateway import status
@ -1331,7 +1332,6 @@ class TestSystemServiceIdentityRootHandling:
def test_explicit_root_is_allowed(self, monkeypatch): def test_explicit_root_is_allowed(self, monkeypatch):
"""When root is explicitly passed via --run-as-user root, allow it.""" """When root is explicitly passed via --run-as-user root, allow it."""
import grp
root_info = pwd.getpwnam("root") root_info = pwd.getpwnam("root")
root_group = grp.getgrgid(root_info.pw_gid).gr_name root_group = grp.getgrgid(root_info.pw_gid).gr_name

View File

@ -108,121 +108,108 @@ class TestEnsureUv:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestRebuildVenv: class TestRebuildVenv:
def test_removes_old_venv_and_creates_new(self, tmp_path): def test_moves_old_venv_aside_and_creates_new(self, tmp_path):
"""The old venv is moved aside to <venv>.old (never rmtree'd in place),
uv is invoked with --clear, the moved-aside backup is removed on
success, and the rebuilt interpreter is reported."""
venv_dir = tmp_path / "venv" venv_dir = tmp_path / "venv"
venv_dir.mkdir() venv_dir.mkdir()
(venv_dir / "old_file").write_text("stale") (venv_dir / "old_file").write_text("stale")
uv_bin = str(tmp_path / "bin" / "uv") uv_bin = str(tmp_path / "bin" / "uv")
call_log: list[list[str]] = []
def fake_run(cmd, **kwargs): def fake_run(cmd, **kwargs):
m = MagicMock(returncode=0) call_log.append(list(cmd))
if cmd[1] == "venv": m = MagicMock(returncode=0, stderr="", stdout="")
# Simulate uv creating the venv dir if len(cmd) >= 2 and cmd[1] == "venv":
venv_dir.mkdir(exist_ok=True) # Simulate uv creating the venv dir with a python interpreter
bin_dir = venv_dir / "bin" bin_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin")
bin_dir.mkdir(parents=True, exist_ok=True) bin_dir.mkdir(parents=True, exist_ok=True)
(bin_dir / "python").write_text("#!/bin/sh\necho Python 3.11.0") python_name = "python.exe" if os.name == "nt" else "python"
(bin_dir / python_name).write_text("#!/bin/sh\necho Python 3.11.0")
elif "--version" in cmd: elif "--version" in cmd:
m.stdout = "Python 3.11.0" m.stdout = "Python 3.11.0"
return m return m
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run):
patch("hermes_cli.managed_uv.shutil.rmtree") as mock_rmtree:
from hermes_cli.managed_uv import rebuild_venv from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir) result = rebuild_venv(uv_bin, venv_dir)
assert result is True
mock_rmtree.assert_called_once_with(venv_dir, ignore_errors=True) assert result is True
# uv venv was invoked exactly once, always with --clear.
venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"]
assert len(venv_calls) == 1, f"expected 1 venv call, got {venv_calls}"
assert "--clear" in venv_calls[0]
# The moved-aside backup is cleaned up after a successful rebuild.
assert not (tmp_path / "venv.old").exists()
def test_aborts_without_deleting_when_venv_in_use(self, tmp_path):
"""If os.replace fails (Windows file lock — venv in use), we must abort
cleanly WITHOUT deleting the venv and WITHOUT invoking uv."""
venv_dir = tmp_path / "venv"
venv_dir.mkdir()
(venv_dir / "locked") .write_text("held open")
uv_bin = str(tmp_path / "bin" / "uv")
call_log: list[list[str]] = []
def fake_run(cmd, **kwargs):
call_log.append(list(cmd))
return MagicMock(returncode=0, stderr="", stdout="")
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
patch("hermes_cli.managed_uv.os.replace", side_effect=OSError("in use")):
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is False
# venv left fully intact, uv never invoked.
assert venv_dir.exists() and (venv_dir / "locked").exists()
assert [c for c in call_log if len(c) >= 2 and c[1] == "venv"] == []
def test_restores_backup_when_rebuild_fails(self, tmp_path):
"""If uv venv exits non-zero, the moved-aside venv is restored so we
never leave Hermes with no venv at all."""
venv_dir = tmp_path / "venv"
venv_dir.mkdir()
(venv_dir / "marker").write_text("original")
uv_bin = str(tmp_path / "bin" / "uv")
def fake_run(cmd, **kwargs):
return MagicMock(returncode=1, stderr="boom", stdout="")
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run):
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is False
# Original venv restored from the .old backup.
assert venv_dir.exists() and (venv_dir / "marker").read_text() == "original"
assert not (tmp_path / "venv.old").exists()
def test_rebuild_failure_returns_false(self, tmp_path): def test_rebuild_failure_returns_false(self, tmp_path):
venv_dir = tmp_path / "venv" venv_dir = tmp_path / "venv"
uv_bin = str(tmp_path / "bin" / "uv") uv_bin = str(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \ with patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
patch("hermes_cli.managed_uv.shutil.rmtree"):
mock_run.return_value = MagicMock(returncode=1, stderr="nope") mock_run.return_value = MagicMock(returncode=1, stderr="nope")
from hermes_cli.managed_uv import rebuild_venv from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir) result = rebuild_venv(uv_bin, venv_dir)
assert result is False assert result is False
def test_retries_with_clear_when_dir_already_exists(self, tmp_path): def test_rebuild_success_without_python_returns_false(self, tmp_path):
"""On Windows, rmtree can silently fail when an open handle holds a """uv can exit 0 yet leave no interpreter; that must not count as success
file in the venv (running hermes.exe, gateway, AV scanner). uv then (guard adapted from #38511)."""
refuses with ``Caused by: A directory already exists at: venv``.
Make sure we don't give up — retry with ``--clear`` to force uv past
the stale directory and rebuild successfully."""
venv_dir = tmp_path / "venv"
venv_dir.mkdir()
(venv_dir / "stale_open_handle").write_text("rmtree couldn't delete me")
uv_bin = str(tmp_path / "bin" / "uv")
call_log: list[list[str]] = []
def fake_run(cmd, **kwargs):
call_log.append(list(cmd))
m = MagicMock()
if cmd[1] == "venv" and "--clear" not in cmd:
# First attempt: uv refuses because dir still exists
m.returncode = 1
m.stderr = (
"error: Failed to create virtual environment\n"
" Caused by: A directory already exists at: venv\n"
"hint: Use the `--clear` flag or set `UV_VENV_CLEAR=1` to replace the existing directory\n"
)
m.stdout = ""
return m
if cmd[1] == "venv" and "--clear" in cmd:
# Retry: succeeds. Simulate uv writing the python shim.
m.returncode = 0
m.stderr = ""
m.stdout = ""
bin_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin")
bin_dir.mkdir(parents=True, exist_ok=True)
python_name = "python.exe" if os.name == "nt" else "python"
(bin_dir / python_name).write_text("#!/bin/sh\necho Python 3.11.0")
return m
if "--version" in cmd:
m.returncode = 0
m.stdout = "Python 3.11.0"
m.stderr = ""
return m
m.returncode = 0
return m
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
patch("hermes_cli.managed_uv.shutil.rmtree"):
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is True, "rebuild should succeed after --clear retry"
# We expect exactly two ``uv venv`` calls: one without --clear, one with.
venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"]
assert len(venv_calls) == 2, f"expected 2 venv calls, got {venv_calls}"
assert "--clear" not in venv_calls[0], "first call should not pass --clear"
assert "--clear" in venv_calls[1], "retry must pass --clear"
def test_does_not_retry_when_first_failure_is_not_dir_exists(self, tmp_path):
"""If uv venv fails for some other reason (e.g. interpreter download
failed, disk full), we should NOT silently retry with --clear
that would mask a real problem. Just surface the original failure."""
venv_dir = tmp_path / "venv" venv_dir = tmp_path / "venv"
uv_bin = str(tmp_path / "bin" / "uv") uv_bin = str(tmp_path / "bin" / "uv")
call_log: list[list[str]] = []
def fake_run(cmd, **kwargs): with patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
call_log.append(list(cmd)) mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
m = MagicMock(returncode=1, stderr="error: No space left on device", stdout="")
return m
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
patch("hermes_cli.managed_uv.shutil.rmtree"):
from hermes_cli.managed_uv import rebuild_venv from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir) result = rebuild_venv(uv_bin, venv_dir)
assert result is False
assert result is False # Returned before the `python --version` probe ran (only the uv venv call).
venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"] assert mock_run.call_count == 1
assert len(venv_calls) == 1, "should not retry on non-dir-exists failures"
assert "--clear" not in venv_calls[0]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -411,7 +411,7 @@ class TestUnionWithPortalFreeRecommendations:
} }
def test_adds_portal_free_model_missing_from_curated(self): def test_adds_portal_free_model_missing_from_curated(self):
"""A Portal-advertised free model not in curated is prepended + priced free.""" """A Portal-advertised free model not in curated is appended + priced free."""
curated = ["anthropic/claude-opus-4.6"] curated = ["anthropic/claude-opus-4.6"]
pricing = {"anthropic/claude-opus-4.6": self._PAID} pricing = {"anthropic/claude-opus-4.6": self._PAID}
with patch( with patch(
@ -420,8 +420,9 @@ class TestUnionWithPortalFreeRecommendations:
): ):
ids, p = union_with_portal_free_recommendations(curated, pricing, "") ids, p = union_with_portal_free_recommendations(curated, pricing, "")
assert ids[0] == "qwen/qwen3.6-plus" # prepended # Curated ("HA") models stay first; Portal-only picks follow.
assert "anthropic/claude-opus-4.6" in ids assert ids[0] == "anthropic/claude-opus-4.6"
assert ids[-1] == "qwen/qwen3.6-plus" # appended
# Synthetic free pricing entry created # Synthetic free pricing entry created
assert p["qwen/qwen3.6-plus"] == self._FREE assert p["qwen/qwen3.6-plus"] == self._FREE
# Existing pricing untouched # Existing pricing untouched
@ -509,7 +510,7 @@ class TestUnionWithPortalFreeRecommendations:
}, },
): ):
ids, p = union_with_portal_free_recommendations(curated, pricing, "") ids, p = union_with_portal_free_recommendations(curated, pricing, "")
assert ids == ["qwen/qwen3.6-plus", "a"] assert ids == ["a", "qwen/qwen3.6-plus"]
assert p["qwen/qwen3.6-plus"] == self._FREE assert p["qwen/qwen3.6-plus"] == self._FREE
@ -535,7 +536,7 @@ class TestUnionWithPortalPaidRecommendations:
} }
def test_adds_portal_paid_model_missing_from_curated(self): def test_adds_portal_paid_model_missing_from_curated(self):
"""A Portal-advertised paid model not in curated is prepended.""" """A Portal-advertised paid model not in curated is appended."""
curated = ["anthropic/claude-opus-4.6"] curated = ["anthropic/claude-opus-4.6"]
pricing = {"anthropic/claude-opus-4.6": self._PAID} pricing = {"anthropic/claude-opus-4.6": self._PAID}
with patch( with patch(
@ -544,8 +545,9 @@ class TestUnionWithPortalPaidRecommendations:
): ):
ids, p = union_with_portal_paid_recommendations(curated, pricing, "") ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
assert ids[0] == "openai/gpt-5.4" # prepended # Curated ("HA") models stay first; Portal-only picks follow.
assert "anthropic/claude-opus-4.6" in ids assert ids[0] == "anthropic/claude-opus-4.6"
assert ids[-1] == "openai/gpt-5.4" # appended
# Existing pricing untouched # Existing pricing untouched
assert p["anthropic/claude-opus-4.6"] == self._PAID assert p["anthropic/claude-opus-4.6"] == self._PAID
@ -634,12 +636,12 @@ class TestUnionWithPortalPaidRecommendations:
}, },
): ):
ids, p = union_with_portal_paid_recommendations(curated, pricing, "") ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
assert ids == ["openai/gpt-5.4", "a"] assert ids == ["a", "openai/gpt-5.4"]
# No synthetic entry — pricing is untouched. # No synthetic entry — pricing is untouched.
assert "openai/gpt-5.4" not in p assert "openai/gpt-5.4" not in p
def test_preserves_relative_order_of_new_paid_models(self): def test_preserves_relative_order_of_new_paid_models(self):
"""Multiple new paid models are prepended in payload order.""" """Multiple new paid models are appended in payload order, after curated."""
curated = ["anthropic/claude-opus-4.6"] curated = ["anthropic/claude-opus-4.6"]
pricing = {"anthropic/claude-opus-4.6": self._PAID} pricing = {"anthropic/claude-opus-4.6": self._PAID}
with patch( with patch(
@ -648,9 +650,9 @@ class TestUnionWithPortalPaidRecommendations:
): ):
ids, _ = union_with_portal_paid_recommendations(curated, pricing, "") ids, _ = union_with_portal_paid_recommendations(curated, pricing, "")
assert ids == [ assert ids == [
"anthropic/claude-opus-4.6",
"openai/gpt-5.4", "openai/gpt-5.4",
"openai/gpt-5.5", "openai/gpt-5.5",
"anthropic/claude-opus-4.6",
] ]

View File

@ -21,6 +21,7 @@ from hermes_cli.tools_config import (
_toolset_needs_configuration_prompt, _toolset_needs_configuration_prompt,
CONFIGURABLE_TOOLSETS, CONFIGURABLE_TOOLSETS,
TOOL_CATEGORIES, TOOL_CATEGORIES,
gui_toolset_label,
_visible_providers, _visible_providers,
tools_command, tools_command,
) )
@ -79,6 +80,13 @@ def test_get_platform_tools_uses_default_when_platform_not_configured():
assert enabled.isdisjoint(_DEFAULT_OFF_TOOLSETS) assert enabled.isdisjoint(_DEFAULT_OFF_TOOLSETS)
def test_gui_toolset_label_strips_leading_emoji():
assert gui_toolset_label("🔍 Web Search & Scraping") == "Web Search & Scraping"
assert gui_toolset_label("👁️ Vision / Image Analysis") == "Vision / Image Analysis"
assert gui_toolset_label("🔌 My Plugin") == "My Plugin"
assert gui_toolset_label("Terminal & Processes") == "Terminal & Processes"
def test_configurable_toolsets_include_messaging(): def test_configurable_toolsets_include_messaging():
assert any(ts_key == "messaging" for ts_key, _, _ in CONFIGURABLE_TOOLSETS) assert any(ts_key == "messaging" for ts_key, _, _ in CONFIGURABLE_TOOLSETS)

View File

@ -423,6 +423,41 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path):
assert ".[all]" in install_cmds[0] assert ".[all]" in install_cmds[0]
def test_cmd_update_aborts_when_fresh_managed_uv_rebuild_fails(monkeypatch, tmp_path):
"""A failed fresh managed-uv venv rebuild must not continue into pip install
(guard adapted from #38511)."""
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
recorded = []
def fake_run(cmd, **kwargs):
recorded.append(cmd)
# Tolerant matching: the update flow's exact git invocations vary by
# checkout, so key off the verb. Branch detection must return a real name
# and rev-list a parseable count, or the flow aborts early before it ever
# reaches the venv rebuild this test exercises.
if isinstance(cmd, (list, tuple)) and cmd and cmd[0] == "git":
if "rev-parse" in cmd:
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
if "rev-list" in cmd:
return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
if "pull" in cmd:
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
with patch("hermes_cli.managed_uv.ensure_uv", return_value=("/usr/bin/uv", True)), \
patch("hermes_cli.managed_uv.rebuild_venv", return_value=False), \
pytest.raises(RuntimeError, match="venv rebuild failed"):
hermes_main.cmd_update(SimpleNamespace())
install_cmds = [c for c in recorded if "pip" in c and "install" in c]
assert install_cmds == []
def test_install_with_optional_fallback_honors_custom_group(monkeypatch): def test_install_with_optional_fallback_honors_custom_group(monkeypatch):
"""Termux update path should target .[termux-all] when requested.""" """Termux update path should target .[termux-all] when requested."""
calls = [] calls = []

View File

@ -815,6 +815,24 @@ class TestWebServerEndpoints:
for key, info in data.items(): for key, info in data.items():
assert info["channel_managed"] is (key in channel_keys) assert info["channel_managed"] is (key in channel_keys)
def test_platform_scoped_messaging_env_vars_are_channel_managed(self):
from hermes_cli.web_server import (
_MESSAGING_KEYS_PAGE_KEYS,
_build_catalog_entry,
_channel_managed_env_keys,
)
discord = _build_catalog_entry("discord")
assert "DISCORD_HOME_CHANNEL" in discord["env_vars"]
assert "DISCORD_ALLOW_ALL_USERS" in discord["env_vars"]
managed = _channel_managed_env_keys()
assert "DISCORD_HOME_CHANNEL" in managed
assert "BLUEBUBBLES_ALLOW_ALL_USERS" in managed
assert "MATTERMOST_ALLOW_ALL_USERS" in managed
assert "GATEWAY_PROXY_URL" not in managed
assert "GATEWAY_PROXY_URL" in _MESSAGING_KEYS_PAGE_KEYS
def test_reveal_env_var(self, tmp_path): def test_reveal_env_var(self, tmp_path):
"""POST /api/env/reveal should return the real unredacted value.""" """POST /api/env/reveal should return the real unredacted value."""
from hermes_cli.config import save_env_value from hermes_cli.config import save_env_value
@ -974,6 +992,157 @@ class TestWebServerEndpoints:
assert data["state"] == "not_configured" assert data["state"] == "not_configured"
assert "DISCORD_BOT_TOKEN" in data["message"] assert "DISCORD_BOT_TOKEN" in data["message"]
def test_telegram_onboarding_start_strips_poll_token(self, monkeypatch):
import hermes_cli.web_server as ws
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
calls = []
def fake_request(method, path, *, body=None, bearer_token=None):
calls.append((method, path, body, bearer_token))
return {
"pairing_id": "pair123",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair123_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair123_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair123_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
resp = self.client.post(
"/api/messaging/telegram/onboarding/start",
json={"bot_name": "Hosted Hermes"},
)
assert resp.status_code == 200
data = resp.json()
assert data["pairing_id"] == "pair123"
assert "poll_token" not in data
assert calls == [
(
"POST",
"/v1/telegram/pairings",
{"bot_name": "Hosted Hermes"},
None,
)
]
def test_telegram_onboarding_ready_and_apply_never_returns_bot_token(self, monkeypatch):
import hermes_cli.web_server as ws
from hermes_cli.config import load_config, load_env
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
def fake_request(method, path, *, body=None, bearer_token=None):
if method == "POST":
return {
"pairing_id": "pair-ready",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair_ready_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_ready_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_ready_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
assert method == "GET"
assert path == "/v1/telegram/pairings/pair-ready"
assert bearer_token == "poll-secret"
return {
"status": "ready",
"bot_username": "hermes_pair_ready_bot",
"owner_user_id": 123456789,
"token": "123456:SECRET",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
ready = self.client.get("/api/messaging/telegram/onboarding/pair-ready")
assert ready.status_code == 200
ready_data = ready.json()
assert ready_data["status"] == "ready"
assert ready_data["owner_user_id"] == "123456789"
assert "token" not in ready_data
applied = self.client.post(
"/api/messaging/telegram/onboarding/pair-ready/apply",
json={"allowed_user_ids": ["123456789", "123456789"]},
)
assert applied.status_code == 200
applied_data = applied.json()
assert applied_data == {
"ok": True,
"platform": "telegram",
"bot_username": "hermes_pair_ready_bot",
"needs_restart": True,
}
env = load_env()
assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET"
assert env["TELEGRAM_ALLOWED_USERS"] == "123456789"
assert load_config()["platforms"]["telegram"]["enabled"] is True
def test_telegram_onboarding_apply_requires_ready_pairing(self, monkeypatch):
import hermes_cli.web_server as ws
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
def fake_request(method, path, *, body=None, bearer_token=None):
return {
"pairing_id": "pair-waiting",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair_waiting_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_waiting_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_waiting_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
resp = self.client.post(
"/api/messaging/telegram/onboarding/pair-waiting/apply",
json={"allowed_user_ids": ["123456789"]},
)
assert resp.status_code == 409
assert "not ready" in resp.json()["detail"]
def test_telegram_onboarding_cancel_clears_local_session(self, monkeypatch):
import hermes_cli.web_server as ws
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
def fake_request(method, path, *, body=None, bearer_token=None):
return {
"pairing_id": "pair-cancel",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair_cancel_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_cancel_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_cancel_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
cancel = self.client.delete("/api/messaging/telegram/onboarding/pair-cancel")
assert cancel.status_code == 200
status = self.client.get("/api/messaging/telegram/onboarding/pair-cancel")
assert status.status_code == 404
def test_session_token_endpoint_removed(self): def test_session_token_endpoint_removed(self):
"""GET /api/auth/session-token should no longer exist (token injected via HTML).""" """GET /api/auth/session-token should no longer exist (token injected via HTML)."""
resp = self.client.get("/api/auth/session-token") resp = self.client.get("/api/auth/session-token")
@ -1967,7 +2136,7 @@ class TestNewEndpoints:
assert resp.json() == [ assert resp.json() == [
{ {
"name": "web", "name": "web",
"label": "🔍 Web Search & Scraping", "label": "Web Search & Scraping",
"description": "web_search, web_extract", "description": "web_search, web_extract",
"enabled": True, "enabled": True,
"available": True, "available": True,
@ -1976,7 +2145,7 @@ class TestNewEndpoints:
}, },
{ {
"name": "skills", "name": "skills",
"label": "📚 Skills", "label": "Skills",
"description": "list, view, manage", "description": "list, view, manage",
"enabled": True, "enabled": True,
"available": True, "available": True,
@ -1985,7 +2154,7 @@ class TestNewEndpoints:
}, },
{ {
"name": "memory", "name": "memory",
"label": "💾 Memory", "label": "Memory",
"description": "persistent memory across sessions", "description": "persistent memory across sessions",
"enabled": False, "enabled": False,
"available": False, "available": False,
@ -4015,4 +4184,3 @@ class TestValidateProviderCredential:
def test_empty_value_rejected(self): def test_empty_value_rejected(self):
data = self._post("OPENAI_API_KEY", " ").json() data = self._post("OPENAI_API_KEY", " ").json()
assert data["ok"] is False assert data["ok"] is False

View File

@ -0,0 +1,90 @@
import asyncio
from hermes_cli import web_server
class _FakeSessionDB:
"""Fake backing the /api/sessions/search endpoint.
The endpoint surfaces direct session-id matches first, then FTS message
matches, deduping both by compression lineage root. This fake has no
compression chains (get_session returns no parent), so each session is its
own lineage root.
"""
closed = False
def search_sessions_by_id(self, query, limit=20, include_archived=True):
assert query == "20260603"
assert include_archived is True
return [
{
"id": "20260603_090200_exact",
"preview": "ID match preview",
"source": "cli",
"model": "claude",
"started_at": 100,
}
]
def search_messages(self, query, limit=20):
assert query == "20260603*"
return [
{
"session_id": "20260603_090200_exact",
"snippet": "duplicate content hit should not replace ID hit",
"role": "user",
"source": "cli",
"model": "claude",
"session_started": 100,
},
{
"session_id": "content_session",
"snippet": "content hit",
"role": "assistant",
"source": "desktop",
"model": "gpt",
"session_started": 200,
},
]
def get_session(self, session_id):
# No compression chains in this fixture — every session is its own root.
return {"id": session_id, "parent_session_id": None}
def get_compression_tip(self, session_id):
return session_id
def close(self):
self.closed = True
def test_desktop_session_search_merges_id_matches_before_content_matches(monkeypatch):
monkeypatch.setattr("hermes_state.SessionDB", _FakeSessionDB)
response = asyncio.run(web_server.search_sessions(q="20260603", limit=2))
# ID match surfaces first; the content hit on the SAME session is deduped
# by lineage root (not double-listed); the unrelated content hit follows.
assert response == {
"results": [
{
"session_id": "20260603_090200_exact",
"lineage_root": "20260603_090200_exact",
"snippet": "ID match preview",
"role": None,
"source": "cli",
"model": "claude",
"session_started": 100,
},
{
"session_id": "content_session",
"lineage_root": "content_session",
"snippet": "content hit",
"role": "assistant",
"source": "desktop",
"model": "gpt",
"session_started": 200,
},
]
}

View File

@ -231,3 +231,53 @@ class TestOpenVikingBrowse:
"/api/v1/fs/ls", "/api/v1/fs/ls",
{"uri": "viking://user/hermes"}, {"uri": "viking://user/hermes"},
)] )]
class TestOpenVikingMemoryUriBuilder:
"""Regression tests for _build_memory_uri — fixes #36969.
Before the fix the URI omitted /agent/{agent}/, causing all agents
under the same user to share the same memory namespace.
"""
def _make_provider(self, user="alice", agent="coder"):
p = OpenVikingMemoryProvider.__new__(OpenVikingMemoryProvider)
p._user = user
p._agent = agent
return p
def test_uri_layout_includes_agent_segment(self):
"""URI must contain /agent/{agent}/ between user and memories."""
p = self._make_provider(user="alice", agent="coder")
uri = p._build_memory_uri("preferences")
assert uri.startswith("viking://user/alice/agent/coder/memories/preferences/mem_")
assert uri.endswith(".md")
def test_uri_uses_configured_agent_not_default(self):
"""_agent value must be interpolated — not hardcoded to 'hermes'."""
p = self._make_provider(user="alice", agent="research-bot")
uri = p._build_memory_uri("entities")
assert "/agent/research-bot/" in uri
assert "/agent/hermes/" not in uri
def test_uri_slug_is_twelve_hex_chars_and_unique(self):
"""Slug must be 12 hex chars and differ between calls."""
import re
p = self._make_provider()
uri1 = p._build_memory_uri("preferences")
uri2 = p._build_memory_uri("preferences")
slug1 = uri1.split("/mem_")[1].replace(".md", "")
slug2 = uri2.split("/mem_")[1].replace(".md", "")
assert re.fullmatch(r"[0-9a-f]{12}", slug1)
assert re.fullmatch(r"[0-9a-f]{12}", slug2)
assert slug1 != slug2
def test_uri_subdir_placed_correctly_for_all_categories(self):
"""All five category subdirs must appear between memories/ and slug."""
p = self._make_provider(user="u", agent="a")
subdirs = ["preferences", "entities", "events", "cases", "patterns"]
for subdir in subdirs:
uri = p._build_memory_uri(subdir)
assert f"/memories/{subdir}/mem_" in uri, (
f"subdir '{subdir}' not placed correctly in URI: {uri}"
)

View File

@ -170,6 +170,135 @@ class TestGuessCategory:
assert dg.guess_category(p) is None assert dg.guess_category(p) is None
class TestStaleCronEntryMigration:
"""Regression tests for #37721 — stale cron-output entries in tracked.json."""
def test_quick_skips_stale_cron_output_for_jobs_json(self, _isolate_env):
"""A stale tracked.json entry with category="cron-output" for
cron/jobs.json must NOT be deleted by quick().
This is the exact scenario from #37721: an old tracked.json has
{"path": ".../cron/jobs.json", "category": "cron-output"} which
would pass the delete filter but must be skipped because
guess_category() now returns None for non-output cron paths.
"""
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
jobs_json = cron_dir / "jobs.json"
jobs_json.write_text('{"jobs": []}')
# Simulate a stale tracked.json entry from before #34840 by
# directly writing the tracked file (track() would reject it).
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
tracked_file.parent.mkdir(parents=True, exist_ok=True)
tracked_file.write_text(json.dumps([{
"path": str(jobs_json),
"category": "cron-output",
"timestamp": "2025-01-01T00:00:00+00:00", # very old
"size": 123,
}]))
summary = dg.quick()
assert summary["deleted"] == 0, "cron/jobs.json must not be deleted"
assert jobs_json.exists(), "jobs.json must still exist"
# The stale entry should have been dropped from tracking.
remaining = json.loads(tracked_file.read_text())
assert len(remaining) == 0
def test_quick_skips_stale_cron_output_for_cron_dir(self, _isolate_env):
"""Stale entry for the cron/ directory itself must not be deleted."""
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
output_dir = cron_dir / "output"
output_dir.mkdir()
(output_dir / "run.md").write_text("x")
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
tracked_file.parent.mkdir(parents=True, exist_ok=True)
tracked_file.write_text(json.dumps([{
"path": str(cron_dir),
"category": "cron-output",
"timestamp": "2025-01-01T00:00:00+00:00",
"size": 0,
}]))
summary = dg.quick()
assert summary["deleted"] == 0, "cron/ dir must not be deleted"
assert cron_dir.exists()
def test_quick_skips_protected_cron_paths_defense_in_depth(self, _isolate_env):
"""Defense-in-depth: even if guess_category returned cron-output
(hypothetically), protected cron paths are never deleted."""
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
tick_lock = cron_dir / ".tick.lock"
tick_lock.write_text("")
# Manually inject a stale entry with "test" category (would normally
# be auto-deleted) — the protected path guard must still block it.
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
tracked_file.parent.mkdir(parents=True, exist_ok=True)
tracked_file.write_text(json.dumps([{
"path": str(tick_lock),
"category": "test",
"timestamp": "2025-01-01T00:00:00+00:00",
"size": 0,
}]))
summary = dg.quick()
assert summary["deleted"] == 0, ".tick.lock must not be deleted"
assert tick_lock.exists()
def test_dry_run_omits_stale_cron_output(self, _isolate_env):
"""dry_run() should also skip stale cron-output entries."""
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
jobs_json = cron_dir / "jobs.json"
jobs_json.write_text("[]")
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
tracked_file.parent.mkdir(parents=True, exist_ok=True)
tracked_file.write_text(json.dumps([{
"path": str(jobs_json),
"category": "cron-output",
"timestamp": "2025-01-01T00:00:00+00:00",
"size": 123,
}]))
auto, prompt = dg.dry_run()
assert len(auto) == 0, "stale cron-output for jobs.json must not appear"
assert len(prompt) == 0
def test_legitimate_cron_output_still_deleted(self, _isolate_env):
"""A valid cron-output entry under cron/output/ must still be deleted."""
dg = _load_lib()
output_dir = _isolate_env / "cron" / "output" / "job_1"
output_dir.mkdir(parents=True)
run_md = output_dir / "run.md"
run_md.write_text("x")
# Old enough to be deleted (>14 days)
from datetime import datetime, timezone, timedelta
old_ts = (datetime.now(timezone.utc) - timedelta(days=20)).isoformat()
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
tracked_file.parent.mkdir(parents=True, exist_ok=True)
tracked_file.write_text(json.dumps([{
"path": str(run_md),
"category": "cron-output",
"timestamp": old_ts,
"size": 10,
}]))
summary = dg.quick()
assert summary["deleted"] == 1, "valid old cron-output should be deleted"
assert not run_md.exists()
class TestTrackForgetQuick: class TestTrackForgetQuick:
def test_track_then_quick_deletes_test(self, _isolate_env): def test_track_then_quick_deletes_test(self, _isolate_env):
dg = _load_lib() dg = _load_lib()

View File

@ -2716,6 +2716,44 @@ class TestListSessionsRich:
ids = [s["id"] for s in sessions] ids = [s["id"] for s in sessions]
assert "branch" in ids, "Branch session should be visible in default list" assert "branch" in ids, "Branch session should be visible in default list"
def test_branch_session_visible_after_parent_reopen_and_reend(self, db):
"""Branch sessions stay visible after the parent is reopened and re-ended.
Regression for issue #20856: /branch (aka /fork) sessions vanished from
/resume and /sessions once the parent was reopened (e.g. resumed) and
re-ended with a different end_reason tui_shutdown overwriting
'branched' which broke the legacy end_reason heuristic. The stable
_branched_from marker in model_config keeps them visible.
"""
import json as _json
db.create_session("parent", "cli")
db.end_session("parent", "branched")
db.create_session(
"branch",
"cli",
model_config={"_branched_from": "parent"},
parent_session_id="parent",
)
db.append_message("branch", "user", "Exploring the alternative approach")
# Marker is persisted at creation time.
branch_row = db.get_session("branch")
cfg = _json.loads(branch_row["model_config"]) if branch_row["model_config"] else {}
assert cfg.get("_branched_from") == "parent"
# Visible immediately after branching.
assert "branch" in [s["id"] for s in db.list_sessions_rich()]
# Parent reopened + re-ended with a different reason (the bug trigger).
db.reopen_session("parent")
db.end_session("parent", "tui_shutdown")
# Branch must STILL be visible — the marker survives the parent's
# end_reason churn, unlike the legacy 'branched' heuristic.
ids = [s["id"] for s in db.list_sessions_rich()]
assert "branch" in ids, "Branch should stay visible after parent re-end"
def test_subagent_session_still_hidden(self, db): def test_subagent_session_still_hidden(self, db):
"""Sub-agent children (parent NOT ended with 'branched') remain hidden.""" """Sub-agent children (parent NOT ended with 'branched') remain hidden."""
db.create_session("root", "cli") db.create_session("root", "cli")
@ -3786,3 +3824,57 @@ class TestSessionArchive:
both = {s["id"] for s in db.list_sessions_rich(include_archived=True)} both = {s["id"] for s in db.list_sessions_rich(include_archived=True)}
assert both == {"live", "hidden"} assert both == {"live", "hidden"}
assert db.session_count(include_archived=True) == 2 assert db.session_count(include_archived=True) == 2
class TestSessionIdSearch:
"""Session id search backs Desktop's Search Sessions UX."""
def _seed(self, db, sid, *, content="ordinary message", archived=False):
db.create_session(session_id=sid, source="cli", model="test-model")
db.append_message(session_id=sid, role="user", content=content)
if archived:
db.set_session_archived(sid, True)
def test_search_sessions_by_id_matches_exact_prefix_and_substring(self, db):
self._seed(db, "20260603_090200_abcd12", content="content without id")
self._seed(db, "20260602_111111_other99", content="other content")
assert [s["id"] for s in db.search_sessions_by_id("20260603_090200_abcd12")] == [
"20260603_090200_abcd12"
]
assert [s["id"] for s in db.search_sessions_by_id("20260603")] == ["20260603_090200_abcd12"]
assert [s["id"] for s in db.search_sessions_by_id("ABCD12")] == ["20260603_090200_abcd12"]
def test_search_sessions_by_id_respects_limit_and_prioritizes_exact_matches(self, db):
self._seed(db, "20260603_090200_abcd12")
self._seed(db, "20260603_090200_abcd12_child")
self._seed(db, "x_20260603_090200_abcd12")
ids = [s["id"] for s in db.search_sessions_by_id("20260603_090200_abcd12", limit=2)]
assert ids == ["20260603_090200_abcd12", "20260603_090200_abcd12_child"]
def test_search_sessions_by_id_can_include_or_exclude_archived(self, db):
self._seed(db, "20260603_090200_live")
self._seed(db, "20260603_090200_archived", archived=True)
included = {s["id"] for s in db.search_sessions_by_id("20260603_090200", include_archived=True)}
excluded = {s["id"] for s in db.search_sessions_by_id("20260603_090200", include_archived=False)}
assert included == {"20260603_090200_live", "20260603_090200_archived"}
assert excluded == {"20260603_090200_live"}
def test_search_sessions_by_id_matches_projected_lineage_root_id(self, db):
root = "20260602_235959_root99"
tip = "20260603_010000_tip01"
db.create_session(session_id=root, source="cli")
db.append_message(root, role="user", content="root conversation")
db.end_session(root, "compression")
db.create_session(session_id=tip, source="cli", parent_session_id=root)
db.append_message(tip, role="user", content="continued conversation")
matches = db.search_sessions_by_id("root99")
assert [s["id"] for s in matches] == [tip]
assert matches[0]["_lineage_root_id"] == root

View File

@ -372,7 +372,8 @@ class TestVisionDispatchLoopSafety:
side_effect=lambda url, dest, **kw: _write_fake_image(dest), side_effect=lambda url, dest, **kw: _write_fake_image(dest),
), ),
patch( patch(
"tools.vision_tools._validate_image_url", "tools.vision_tools._validate_image_url_async",
new_callable=AsyncMock,
return_value=True, return_value=True,
), ),
patch( patch(
@ -416,7 +417,8 @@ class TestVisionDispatchLoopSafety:
side_effect=lambda url, dest, **kw: _write_fake_image(dest), side_effect=lambda url, dest, **kw: _write_fake_image(dest),
), ),
patch( patch(
"tools.vision_tools._validate_image_url", "tools.vision_tools._validate_image_url_async",
new_callable=AsyncMock,
return_value=True, return_value=True,
), ),
patch( patch(

View File

@ -88,6 +88,17 @@ def test_lazy_installable_extras_excluded_from_all():
) )
def test_dev_extra_excluded_from_all():
"""End-user installs should not pull test/lint/debug tooling."""
optional_dependencies = _load_optional_dependencies()
assert "dev" in optional_dependencies
assert not any(
spec == "hermes-agent[dev]"
for spec in optional_dependencies["all"]
)
def test_messaging_extra_includes_qrcode_for_weixin_setup(): def test_messaging_extra_includes_qrcode_for_weixin_setup():
optional_dependencies = _load_optional_dependencies() optional_dependencies = _load_optional_dependencies()

View File

@ -5474,6 +5474,8 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
assert requeued["session_id"] == "proc_busy_test" assert requeued["session_id"] == "proc_busy_test"
finally: finally:
server._sessions.pop("sid_busy", None) server._sessions.pop("sid_busy", None)
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
def test_session_save_writes_under_hermes_home_with_system_prompt(monkeypatch, tmp_path): def test_session_save_writes_under_hermes_home_with_system_prompt(monkeypatch, tmp_path):
@ -5533,3 +5535,95 @@ def test_session_save_writes_under_hermes_home_with_system_prompt(monkeypatch, t
assert payload["session_start"] == "2026-01-01T12:00:00" assert payload["session_start"] == "2026-01-01T12:00:00"
assert payload["system_prompt"] == "You are Hermes." assert payload["system_prompt"] == "You are Hermes."
assert payload["messages"] == history assert payload["messages"] == history
def test_notification_event_dedup_key_preserves_distinct_watch_matches():
"""Watch-match identity includes match content, not just session/type."""
base = {
"type": "watch_match",
"session_id": "proc_watch",
"command": "tail -f app.log",
"pattern": "READY",
"output": "READY on port 8000",
"suppressed": 0,
}
identical = dict(base)
distinct_output = {**base, "output": "READY on port 9000"}
distinct_pattern = {**base, "pattern": "MIGRATION_DONE"}
base_key = server._notification_event_dedup_key(base)
assert server._notification_event_dedup_key(identical) == base_key
assert server._notification_event_dedup_key(distinct_output) != base_key
assert server._notification_event_dedup_key(distinct_pattern) != base_key
def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch):
"""Distinct watch matches from one process emit; exact replay is deduped."""
from tools.process_registry import process_registry
turns = []
emitted = []
def _fake_run_prompt_submit(rid, sid, session, text):
turns.append(text)
with session["history_lock"]:
session["running"] = False
sess = _session()
server._sessions["sid_watch_dedup"] = sess
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
monkeypatch.setattr(server, "_run_prompt_submit", _fake_run_prompt_submit)
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
base = {
"type": "watch_match",
"session_id": "proc_watch_dedup",
"command": "tail -f app.log",
"pattern": "READY",
"output": "READY on port 8000",
"suppressed": 0,
}
process_registry.completion_queue.put(base)
process_registry.completion_queue.put({**base, "output": "READY on port 9000"})
process_registry.completion_queue.put(dict(base))
stop = threading.Event()
stop.set()
try:
server._notification_poller_loop(stop, "sid_watch_dedup", sess)
status_calls = [a for a in emitted if a[0] == "status.update"]
assert len(status_calls) == 2
status_text = "\n".join(call[2]["text"] for call in status_calls)
assert "READY on port 8000" in status_text
assert "READY on port 9000" in status_text
assert len(turns) == 3
finally:
server._sessions.pop("sid_watch_dedup", None)
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
def test_notification_event_dedup_key_keeps_completions_one_shot():
"""Completion identity remains process-session scoped to avoid floods."""
first = {
"type": "completion",
"session_id": "proc_done",
"command": "make build",
"exit_code": 0,
"output": "first output",
}
replay = {
"type": "completion",
"session_id": "proc_done",
"command": "make build --again",
"exit_code": 1,
"output": "different output should not change completion key",
}
assert server._notification_event_dedup_key(first) == server._notification_event_dedup_key(
replay
)

View File

@ -44,6 +44,7 @@ def _make_dummy_env(**kwargs):
auto_mount_cwd=kwargs.get("auto_mount_cwd", False), auto_mount_cwd=kwargs.get("auto_mount_cwd", False),
env=kwargs.get("env"), env=kwargs.get("env"),
run_as_host_user=kwargs.get("run_as_host_user", False), run_as_host_user=kwargs.get("run_as_host_user", False),
persist_across_processes=kwargs.get("persist_across_processes", True),
) )
@ -786,6 +787,84 @@ def test_reuse_falls_back_to_fresh_run_when_start_fails(monkeypatch):
assert run_invocations, "fallback to fresh docker run must happen on start failure" assert run_invocations, "fallback to fresh docker run must happen on start failure"
def test_failed_docker_run_cleans_up_orphaned_container(monkeypatch):
"""When ``docker run`` fails (e.g. exit 125), the partially-created
container must be removed by name.
Docker can create the container object before failing to start it,
leaving a stale ``Created`` container. The exited-only orphan reaper
(``reap_orphan_containers``, ``status=exited``) never catches a
``Created`` orphan, so without this cleanup it leaks permanently.
Regression for #7439. Salvage of #7440 (@Tranquil-Flow).
"""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default")
cleanup_calls = []
def _run(cmd, **kwargs):
if isinstance(cmd, list) and len(cmd) >= 2:
sub = cmd[1]
if sub == "version":
return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="")
if sub == "ps":
# No reusable container -> fall through to a fresh `docker run`.
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
if sub == "run":
raise subprocess.CalledProcessError(
125, cmd, output="", stderr="docker: Error response from daemon"
)
if sub == "rm":
cleanup_calls.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(docker_env.subprocess, "run", _run)
with pytest.raises(subprocess.CalledProcessError):
_make_dummy_env()
assert len(cleanup_calls) == 1, "docker rm should be called once for the orphaned container"
rm_cmd = cleanup_calls[0]
assert rm_cmd[1] == "rm" and rm_cmd[2] == "-f"
assert rm_cmd[3].startswith("hermes-"), "should remove the container by its generated name"
def test_docker_run_timeout_cleans_up_orphaned_container(monkeypatch):
"""When ``docker run`` times out (e.g. slow image pull), the
partially-created container must be removed. Salvage of #7440
(@Tranquil-Flow); regression for #7439.
"""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default")
cleanup_calls = []
def _run(cmd, **kwargs):
if isinstance(cmd, list) and len(cmd) >= 2:
sub = cmd[1]
if sub == "version":
return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="")
if sub == "ps":
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
if sub == "run":
raise subprocess.TimeoutExpired(cmd, 120)
if sub == "rm":
cleanup_calls.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(docker_env.subprocess, "run", _run)
with pytest.raises(subprocess.TimeoutExpired):
_make_dummy_env()
assert len(cleanup_calls) == 1, "docker rm should be called once for the orphaned container"
rm_cmd = cleanup_calls[0]
assert rm_cmd[1] == "rm" and rm_cmd[2] == "-f"
assert rm_cmd[3].startswith("hermes-"), "should remove the container by its generated name"
def test_no_reuse_when_persist_across_processes_disabled(monkeypatch): def test_no_reuse_when_persist_across_processes_disabled(monkeypatch):
"""Opt-out path: ``persist_across_processes=False`` skips the ps probe """Opt-out path: ``persist_across_processes=False`` skips the ps probe
entirely and always starts a fresh container, matching the pre-fix entirely and always starts a fresh container, matching the pre-fix
@ -1629,3 +1708,128 @@ def test_plain_image_keeps_docker_init_and_run_noexec(monkeypatch):
assert "noexec" in run_mounts[0], ( assert "noexec" in run_mounts[0], (
f"/run must stay noexec for non-s6 images, got: {run_mounts[0]}" f"/run must stay noexec for non-s6 images, got: {run_mounts[0]}"
) )
# ---------------------------------------------------------------------------
# Out-of-band container removal recovery (issue #36266, PR #36631)
# ---------------------------------------------------------------------------
def test_is_container_gone_matches_removal_errors(monkeypatch):
"""``_is_container_gone`` recognizes the docker errors that mean the
container no longer exists, and does NOT match ordinary command failures.
"""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
_mock_subprocess_run(monkeypatch)
env = _make_dummy_env()
# Positive: the daemon's "container gone" phrasings.
assert env._is_container_gone(
"Error response from daemon: No such container: hermes-abc123"
)
assert env._is_container_gone("Error: No such container: deadbeef")
assert env._is_container_gone(
"Error response from daemon: Container abc is not running"
)
# Control / negative: a real command failure must NOT be misclassified as
# the container being gone — otherwise every non-zero exit would trigger a
# spurious container recreation.
assert not env._is_container_gone("bash: nonsuch: command not found")
assert not env._is_container_gone("Traceback (most recent call last): ...")
assert not env._is_container_gone("")
assert not env._is_container_gone("permission denied")
def test_execute_recovers_from_out_of_band_removal(monkeypatch):
"""When a persistent container is removed out-of-band, ``execute`` detects
the "No such container" error, recreates the container, and retries once
returning success transparently.
"""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
_mock_subprocess_run(monkeypatch)
env = _make_dummy_env(
persistent_filesystem=True,
persist_across_processes=True,
)
# First execute() sees a dead container; second (post-recovery) succeeds.
outputs = iter([
{"output": "Error response from daemon: No such container: hermes-x", "returncode": 1},
{"output": "ok", "returncode": 0},
])
def _fake_super_execute(self, command, cwd="", **kwargs):
return next(outputs)
recreate_calls = []
def _fake_recreate(self):
recreate_calls.append(True)
self._container_id = "recovered-container-id"
return True
monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute)
monkeypatch.setattr(
docker_env.DockerEnvironment, "_recreate_container", _fake_recreate
)
result = env.execute("echo hi")
assert recreate_calls == [True], "recovery should have been attempted exactly once"
assert result.get("returncode") == 0, f"expected success after recovery, got {result!r}"
assert result.get("output") == "ok"
def test_execute_does_not_recover_when_not_persistent(monkeypatch):
"""A non-persistent session must NOT trigger container recreation on a
"No such container" error recovery is only meaningful for the persistent,
cross-process container that can be removed out-of-band.
"""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
_mock_subprocess_run(monkeypatch)
env = _make_dummy_env(
persistent_filesystem=True,
persist_across_processes=False,
)
def _fake_super_execute(self, command, cwd="", **kwargs):
return {"output": "No such container: x", "returncode": 1}
def _fail_recreate(self):
pytest.fail("recreation must not run when persist_across_processes is False")
monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute)
monkeypatch.setattr(
docker_env.DockerEnvironment, "_recreate_container", _fail_recreate
)
result = env.execute("echo hi")
assert result.get("returncode") == 1, "the original error must pass through unchanged"
def test_execute_does_not_recover_on_ordinary_failure(monkeypatch):
"""A genuine non-zero exit that is NOT a container-gone error must pass
through without triggering recovery (guards against over-eager recreation).
"""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
_mock_subprocess_run(monkeypatch)
env = _make_dummy_env(
persistent_filesystem=True,
persist_across_processes=True,
)
def _fake_super_execute(self, command, cwd="", **kwargs):
return {"output": "bash: badcmd: command not found", "returncode": 127}
def _fail_recreate(self):
pytest.fail("recreation must not run for an ordinary command failure")
monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute)
monkeypatch.setattr(
docker_env.DockerEnvironment, "_recreate_container", _fail_recreate
)
result = env.execute("badcmd")
assert result.get("returncode") == 127
assert "command not found" in result.get("output", "")

View File

@ -595,6 +595,7 @@ class TestSendToPlatformChunking:
"***", "***",
"C123", "C123",
"*hello* from <https://example.com|Hermes>", "*hello* from <https://example.com|Hermes>",
thread_ts=None,
) )
def test_slack_bold_italic_formatted_before_send(self, monkeypatch): def test_slack_bold_italic_formatted_before_send(self, monkeypatch):

View File

@ -90,7 +90,12 @@ class TestSSHBulkUpload:
assert "/home/testuser/.hermes/credentials" in mkdir_str assert "/home/testuser/.hermes/credentials" in mkdir_str
def test_staging_symlinks_mirror_remote_layout(self, mock_env, tmp_path): def test_staging_symlinks_mirror_remote_layout(self, mock_env, tmp_path):
"""Symlinks in staging dir should mirror the .hermes-relative layout.""" """Staged file in staging dir should mirror the remote path structure.
On platforms where symlinks are available (Linux/macOS) the staged
entry is a symlink; on Windows it may be a regular copy. Either way
the file must exist at the expected path and contain the right data.
"""
f1 = tmp_path / "local_a.txt" f1 = tmp_path / "local_a.txt"
f1.write_text("content a") f1.write_text("content a")
@ -105,11 +110,14 @@ class TestSSHBulkUpload:
# Capture the staging dir from -C argument # Capture the staging dir from -C argument
c_idx = cmd.index("-C") c_idx = cmd.index("-C")
staging_dir = cmd[c_idx + 1] staging_dir = cmd[c_idx + 1]
# Check the symlink exists # Check the staged entry exists at the base-relative path
expected = os.path.join(staging_dir, "skills/my_skill.md") expected = os.path.join(staging_dir, "skills/my_skill.md")
staging_paths.append(expected) staging_paths.append(expected)
assert os.path.islink(expected), f"Expected symlink at {expected}" # File must exist (either as symlink or copy)
assert os.readlink(expected) == os.path.abspath(str(f1)) assert os.path.exists(expected), f"Expected staged file at {expected}"
# Content must match the source
with open(expected, "r") as fh:
assert fh.read() == "content a"
mock = MagicMock() mock = MagicMock()
mock.stdout = MagicMock() mock.stdout = MagicMock()

View File

@ -156,14 +156,15 @@ def test_cli_and_gateway_env_maps_agree():
def test_save_config_set_supports_critical_bridged_keys(): def test_save_config_set_supports_critical_bridged_keys():
"""``hermes config set terminal.X true`` must propagate to .env for """``hermes config set terminal.X true`` must propagate to .env for
known-critical keys. This used to be an all-keys invariant but several known-critical keys. This used to be an all-keys invariant but the SSH
pre-existing terminal keys (ssh_*, docker_forward_env, docker_volumes) terminal keys (ssh_*) aren't in _config_to_env_sync and are instead
aren't in _config_to_env_sync and are instead handled via the separate handled via the separate api_keys TERMINAL_SSH_* fallback path or
api_keys TERMINAL_SSH_* fallback path or user-edits-yaml-directly. user-edits-yaml-directly.
Until those gaps are audited and fixed, pin the specific keys that are Until those gaps are audited and fixed, pin the specific keys that are
load-bearing for the docker backend's ownership flag so the bug we just load-bearing for the docker backend so the bugs we fixed cannot silently
fixed cannot silently regress. regress. (docker_volumes / docker_forward_env, previously listed here as
gaps, are now bridged see the dedicated tests below.)
""" """
save_keys = _save_config_env_sync_keys() save_keys = _save_config_env_sync_keys()
required = { required = {
@ -260,3 +261,37 @@ def test_docker_orphan_reaper_is_bridged_everywhere():
assert "docker_orphan_reaper" in _gateway_env_map_keys() assert "docker_orphan_reaper" in _gateway_env_map_keys()
assert "docker_orphan_reaper" in _save_config_env_sync_keys() assert "docker_orphan_reaper" in _save_config_env_sync_keys()
assert "TERMINAL_DOCKER_ORPHAN_REAPER" in _terminal_tool_env_var_names() assert "TERMINAL_DOCKER_ORPHAN_REAPER" in _terminal_tool_env_var_names()
def test_docker_volumes_is_bridged_everywhere():
"""Regression pin for ``terminal.docker_volumes`` being silently dropped by
``hermes config set``.
The JSON list of ``host:container`` bind mounts was bridged by cli.py and
gateway/run.py and consumed by terminal_tool (via json.loads), but was
missing from set_config_value's _config_to_env_sync. So
``hermes config set terminal.docker_volumes '["/host:/workspace"]'`` wrote
config.yaml yet left the running process's TERMINAL_DOCKER_VOLUMES stale —
the mounts didn't apply until a full restart. Same four-site bridge
invariant as docker_env / docker_run_as_host_user.
"""
assert "docker_volumes" in _cli_env_map_keys()
assert "docker_volumes" in _gateway_env_map_keys()
assert "docker_volumes" in _save_config_env_sync_keys()
assert "TERMINAL_DOCKER_VOLUMES" in _terminal_tool_env_var_names()
def test_docker_forward_env_is_bridged_everywhere():
"""Regression pin for ``terminal.docker_forward_env`` — the sibling gap to
docker_volumes.
The JSON list of host env-var names forwarded into the container was
bridged by cli.py and gateway/run.py and consumed by terminal_tool (via
json.loads), but missing from set_config_value's _config_to_env_sync, so
``hermes config set terminal.docker_forward_env '["GITHUB_TOKEN"]'`` had no
effect on the running process until restart.
"""
assert "docker_forward_env" in _cli_env_map_keys()
assert "docker_forward_env" in _gateway_env_map_keys()
assert "docker_forward_env" in _save_config_env_sync_keys()
assert "TERMINAL_DOCKER_FORWARD_ENV" in _terminal_tool_env_var_names()

View File

@ -5,6 +5,7 @@ from unittest.mock import patch
from tools.url_safety import ( from tools.url_safety import (
is_safe_url, is_safe_url,
async_is_safe_url,
is_always_blocked_url, is_always_blocked_url,
_is_blocked_ip, _is_blocked_ip,
_global_allow_private_urls, _global_allow_private_urls,
@ -195,6 +196,24 @@ class TestIsSafeUrl:
assert is_safe_url("https://multimedia.nt.qq.com.cn/download?id=123") is False assert is_safe_url("https://multimedia.nt.qq.com.cn/download?id=123") is False
class TestAsyncIsSafeUrl:
"""async_is_safe_url must match is_safe_url (runs DNS in a thread pool)."""
@pytest.mark.asyncio
async def test_public_url_allowed(self):
with patch("socket.getaddrinfo", return_value=[
(2, 1, 6, "", ("93.184.216.34", 0)),
]):
assert await async_is_safe_url("https://example.com/x") is True
@pytest.mark.asyncio
async def test_localhost_blocked(self):
with patch("socket.getaddrinfo", return_value=[
(2, 1, 6, "", ("127.0.0.1", 0)),
]):
assert await async_is_safe_url("http://localhost:8080/") is False
class TestIsBlockedIp: class TestIsBlockedIp:
"""Direct tests for the _is_blocked_ip helper.""" """Direct tests for the _is_blocked_ip helper."""

View File

@ -297,7 +297,7 @@ class TestErrorLoggingExcInfo:
async def test_analysis_error_logs_exc_info(self, caplog): async def test_analysis_error_logs_exc_info(self, caplog):
"""When vision_analyze_tool encounters an error, it should log with exc_info.""" """When vision_analyze_tool encounters an error, it should log with exc_info."""
with ( with (
patch("tools.vision_tools._validate_image_url", return_value=True), patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True),
patch( patch(
"tools.vision_tools._download_image", "tools.vision_tools._download_image",
new_callable=AsyncMock, new_callable=AsyncMock,
@ -329,7 +329,7 @@ class TestErrorLoggingExcInfo:
return dest return dest
with ( with (
patch("tools.vision_tools._validate_image_url", return_value=True), patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True),
patch("tools.vision_tools._download_image", side_effect=fake_download), patch("tools.vision_tools._download_image", side_effect=fake_download),
patch( patch(
"tools.vision_tools._image_to_base64_data_url", "tools.vision_tools._image_to_base64_data_url",
@ -451,7 +451,7 @@ class TestVisionSafetyGuards:
with ( with (
patch("tools.vision_tools.check_website_access", return_value=blocked), patch("tools.vision_tools.check_website_access", return_value=blocked),
patch("tools.vision_tools._validate_image_url", return_value=True), patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True),
patch("tools.vision_tools._download_image", new_callable=AsyncMock) as mock_download, patch("tools.vision_tools._download_image", new_callable=AsyncMock) as mock_download,
): ):
result = json.loads(await vision_analyze_tool("https://blocked.test/cat.png", "describe")) result = json.loads(await vision_analyze_tool("https://blocked.test/cat.png", "describe"))
@ -549,7 +549,9 @@ class TestTildeExpansion:
img = fake_home / "test_image.png" img = fake_home / "test_image.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8) img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8)
# Windows expanduser() prefers USERPROFILE over HOME; POSIX uses HOME.
monkeypatch.setenv("HOME", str(fake_home)) monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setenv("USERPROFILE", str(fake_home))
mock_response = MagicMock() mock_response = MagicMock()
mock_choice = MagicMock() mock_choice = MagicMock()
@ -580,6 +582,7 @@ class TestTildeExpansion:
fake_home = tmp_path / "fakehome" fake_home = tmp_path / "fakehome"
fake_home.mkdir() fake_home.mkdir()
monkeypatch.setenv("HOME", str(fake_home)) monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setenv("USERPROFILE", str(fake_home))
result = await vision_analyze_tool( result = await vision_analyze_tool(
"~/nonexistent.png", "describe this", "test/model" "~/nonexistent.png", "describe this", "test/model"

View File

@ -259,7 +259,10 @@ class TestBraveFreeSearchOnlyErrors:
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "brave-free"}) monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "brave-free"})
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123") monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123")
monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False)
monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) async def _allow_ssrf(_url: str) -> bool:
return True
monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf)
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False)
result_str = asyncio.get_event_loop().run_until_complete( result_str = asyncio.get_event_loop().run_until_complete(

View File

@ -229,7 +229,10 @@ class TestDDGSSearchOnlyErrors:
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "ddgs"}) monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "ddgs"})
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: True) monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: True)
monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False)
monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) async def _allow_ssrf(_url: str) -> bool:
return True
monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf)
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False)
result_str = asyncio.get_event_loop().run_until_complete( result_str = asyncio.get_event_loop().run_until_complete(

View File

@ -318,7 +318,10 @@ class TestSearXNGOnlyExtractCrawlErrors:
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "searxng"}) monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "searxng"})
monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080")
monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False)
monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) async def _allow_ssrf(_url: str) -> bool:
return True
monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf)
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False)
result_str = asyncio.get_event_loop().run_until_complete( result_str = asyncio.get_event_loop().run_until_complete(

View File

@ -372,7 +372,10 @@ class TestWebToolPolicy:
from plugins.web.firecrawl import provider as firecrawl_provider from plugins.web.firecrawl import provider as firecrawl_provider
# Allow test URLs past SSRF check so website policy is what gets tested # Allow test URLs past SSRF check so website policy is what gets tested
monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) async def _allow_ssrf(_url: str) -> bool:
return True
monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf)
# The per-URL website-policy gate moved into the firecrawl plugin's # The per-URL website-policy gate moved into the firecrawl plugin's
# extract() during the web-provider migration. Patch it at the new # extract() during the web-provider migration. Patch it at the new
# location. # location.
@ -406,7 +409,10 @@ class TestWebToolPolicy:
from plugins.web.firecrawl import provider as firecrawl_provider from plugins.web.firecrawl import provider as firecrawl_provider
# Allow test URLs past SSRF check so website policy is what gets tested # Allow test URLs past SSRF check so website policy is what gets tested
monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) async def _allow_ssrf(_url: str) -> bool:
return True
monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf)
def fake_check(url): def fake_check(url):
if url == "https://allowed.test": if url == "https://allowed.test":

View File

@ -394,6 +394,290 @@ def test_session_resume_handles_multimodal_list_content(server, monkeypatch):
] ]
def test_session_resume_reuses_existing_live_session(server, monkeypatch):
"""Repeated resume must not allocate duplicate live agents."""
target = "20260409_010101_abc123"
created_sids: list[str] = []
closed_sids: list[str] = []
first_agent_started = threading.Event()
agent_can_finish = threading.Event()
class _DB:
def get_session(self, _sid):
return {"id": target}
def get_session_by_title(self, _title):
return None
def reopen_session(self, _sid):
return None
def get_messages_as_conversation(self, _sid, include_ancestors=False):
return [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "yo"},
]
class _Worker:
def close(self):
pass
class _Agent:
def __init__(self, sid, session_id):
self.sid = sid
self.model = "test/model"
self.session_id = session_id
def close(self):
closed_sids.append(self.sid)
def make_agent(sid, key, session_id=None):
created_sids.append(sid)
first_agent_started.set()
assert agent_can_finish.wait(timeout=1)
return _Agent(sid, session_id or key)
monkeypatch.setattr(server, "_get_db", lambda: _DB())
monkeypatch.setattr(server, "_make_agent", make_agent)
monkeypatch.setattr(server, "_SlashWorker", lambda _key, _model: _Worker())
monkeypatch.setattr(
server,
"_start_notification_poller",
lambda _sid, _session: threading.Event(),
)
monkeypatch.setattr(server, "_notify_session_boundary", lambda *_args, **_kwargs: None)
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
server,
"_session_info",
lambda _agent, _session=None: {"model": "test/model"},
)
fake_approval = types.SimpleNamespace(
load_permanent_allowlist=lambda: None,
register_gateway_notify=lambda *_args, **_kwargs: None,
)
with patch.dict(sys.modules, {"tools.approval": fake_approval}):
first_holder = {}
def resume_first():
first_holder["resp"] = server.handle_request(
{
"id": "first",
"method": "session.resume",
"params": {"session_id": target, "cols": 100},
}
)
first_thread = threading.Thread(target=resume_first)
first_thread.start()
assert first_agent_started.wait(timeout=1)
second_holder = {}
def resume_second():
second_holder["resp"] = server.handle_request(
{
"id": "second",
"method": "session.resume",
"params": {"session_id": target, "cols": 120},
}
)
second_thread = threading.Thread(target=resume_second)
second_thread.start()
agent_can_finish.set()
first_thread.join(timeout=1)
second_thread.join(timeout=1)
assert not first_thread.is_alive()
assert not second_thread.is_alive()
first = first_holder["resp"]
second = second_holder["resp"]
assert "error" not in first
assert "error" not in second
# Both resumes resolve to the SAME single live session — the core invariant.
assert second["result"]["session_id"] == first["result"]["session_id"]
assert len(server._sessions) == 1
assert [s.get("session_key") for s in server._sessions.values()].count(target) == 1
winner = first["result"]["session_id"]
# The agent build happens outside the resume lock, so a racing resume may
# build a redundant agent; double-checked locking keeps only one live
# session and closes any loser's agent (no worker/poller is wired for it).
assert winner in created_sids
survivors = [sid for sid in created_sids if sid not in closed_sids]
assert survivors == [winner]
assert all(sid == winner for sid in server._sessions)
def test_session_resume_live_payload_uses_current_history_with_ancestors(server, monkeypatch):
"""Live resume should not reuse a stale ancestor-inclusive snapshot."""
target = "20260409_010101_child"
ancestor_history = [{"role": "user", "content": "ancestor"}]
current_history = [
{"role": "user", "content": "current"},
{"role": "assistant", "content": "current reply"},
]
class _DB:
def get_session(self, _sid):
return {"id": target}
def get_session_by_title(self, _title):
return None
def reopen_session(self, _sid):
return None
def get_messages_as_conversation(self, _sid, include_ancestors=False):
if include_ancestors:
return ancestor_history + current_history
return list(current_history)
class _Worker:
def close(self):
pass
monkeypatch.setattr(server, "_get_db", lambda: _DB())
monkeypatch.setattr(
server,
"_make_agent",
lambda _sid, key, session_id=None: types.SimpleNamespace(
model="test/model", session_id=session_id or key
),
)
monkeypatch.setattr(server, "_SlashWorker", lambda _key, _model: _Worker())
monkeypatch.setattr(
server,
"_start_notification_poller",
lambda _sid, _session: threading.Event(),
)
monkeypatch.setattr(server, "_notify_session_boundary", lambda *_args, **_kwargs: None)
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
server,
"_session_info",
lambda _agent, _session=None: {"model": "test/model"},
)
fake_approval = types.SimpleNamespace(
load_permanent_allowlist=lambda: None,
register_gateway_notify=lambda *_args, **_kwargs: None,
)
with patch.dict(sys.modules, {"tools.approval": fake_approval}):
first = server.handle_request(
{
"id": "first",
"method": "session.resume",
"params": {"session_id": target, "cols": 100},
}
)
assert "error" not in first
sid = first["result"]["session_id"]
assert first["result"]["messages"] == [
{"role": "user", "text": "ancestor"},
{"role": "user", "text": "current"},
{"role": "assistant", "text": "current reply"},
]
with server._sessions[sid]["history_lock"]:
server._sessions[sid]["history"] = current_history + [
{"role": "user", "content": "new live turn"},
{"role": "assistant", "content": "new live reply"},
]
second = server.handle_request(
{
"id": "second",
"method": "session.resume",
"params": {"session_id": target, "cols": 120},
}
)
assert "error" not in second
assert second["result"]["session_id"] == sid
assert second["result"]["messages"] == [
{"role": "user", "text": "ancestor"},
{"role": "user", "text": "current"},
{"role": "assistant", "text": "current reply"},
{"role": "user", "text": "new live turn"},
{"role": "assistant", "text": "new live reply"},
]
def test_session_branch_persists_branched_from_marker(server, monkeypatch):
"""TUI /branch must persist a _branched_from marker so the branch stays
visible in /resume and /sessions.
Regression for issue #20856: the TUI branch leaves the parent live (it
never ends it with end_reason='branched'), so list_sessions_rich's legacy
heuristic never surfaces it the stable model_config marker is the only
thing that keeps a TUI branch visible.
"""
create_calls = []
class _DB:
def get_session_title(self, _key):
return "parent-title"
def get_next_title_in_lineage(self, base):
return f"{base} 2"
def create_session(self, new_key, **kwargs):
create_calls.append((new_key, kwargs))
return new_key
def append_message(self, **_kwargs):
return None
def set_session_title(self, _key, _title):
return None
monkeypatch.setattr(server, "_get_db", lambda: _DB())
monkeypatch.setattr(server, "_resolve_model", lambda: "test/model")
monkeypatch.setattr(server, "_new_session_key", lambda: "20260101_000001_child0")
monkeypatch.setattr(
server,
"_make_agent",
lambda _sid, key, session_id=None: types.SimpleNamespace(
model="test/model", session_id=session_id or key
),
)
monkeypatch.setattr(server, "_init_session", lambda *_a, **_k: None)
monkeypatch.setattr(server, "_set_session_context", lambda *_a, **_k: [])
monkeypatch.setattr(server, "_clear_session_context", lambda *_a, **_k: None)
monkeypatch.setattr(server, "_session_cwd", lambda _s: "/tmp/branch-cwd")
parent_sid = "parent01"
parent_key = "20260101_000000_parent"
server._sessions[parent_sid] = {
"session_key": parent_key,
"history": [{"role": "user", "content": "hello"}],
"history_lock": threading.Lock(),
"cols": 80,
}
resp = server.handle_request(
{"id": "b1", "method": "session.branch", "params": {"session_id": parent_sid}}
)
assert "error" not in resp, resp
assert len(create_calls) == 1
new_key, kwargs = create_calls[0]
assert new_key == "20260101_000001_child0"
assert kwargs["parent_session_id"] == parent_key
# The marker — without it the branch is invisible in /resume and /sessions.
assert kwargs["model_config"] == {"_branched_from": parent_key}
def test_make_agent_accepts_list_system_prompt(server, monkeypatch): def test_make_agent_accepts_list_system_prompt(server, monkeypatch):
captured = {} captured = {}

Some files were not shown because too many files have changed in this diff Show More