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:
commit
89baf02919
@ -3,6 +3,21 @@
|
||||
.gitignore
|
||||
.gitmodules
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
@ -24,7 +39,20 @@ ui-tui/packages/hermes-ink/dist/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
|
||||
# Runtime data (bind-mounted at /opt/data; must not leak into build context)
|
||||
|
||||
8
.gitattributes
vendored
8
.gitattributes
vendored
@ -1,2 +1,10 @@
|
||||
# Auto-generated files — collapse diffs and exclude from language stats
|
||||
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
|
||||
|
||||
BIN
.github/pr-screenshots/39327/providers-collapsed.png
vendored
Executable file
BIN
.github/pr-screenshots/39327/providers-collapsed.png
vendored
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
.github/pr-screenshots/39327/providers-expanded.png
vendored
Executable file
BIN
.github/pr-screenshots/39327/providers-expanded.png
vendored
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
.github/pr-screenshots/39327/tools-collapsed.png
vendored
Executable file
BIN
.github/pr-screenshots/39327/tools-collapsed.png
vendored
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
.github/pr-screenshots/39327/tools-expanded.png
vendored
Executable file
BIN
.github/pr-screenshots/39327/tools-expanded.png
vendored
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
||||
.DS_Store
|
||||
/venv/
|
||||
/venv.old/
|
||||
/_pycache/
|
||||
*.pyc*
|
||||
__pycache__/
|
||||
|
||||
@ -33,7 +33,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
|
||||
### Linux, macOS, WSL2, Termux
|
||||
|
||||
```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)
|
||||
@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri
|
||||
Run this in 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.
|
||||
@ -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.
|
||||
>
|
||||
> **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:
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
## 快速安装
|
||||
|
||||
```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)。安装程序会自动处理平台特定的配置。
|
||||
|
||||
@ -457,12 +457,7 @@ class SessionManager:
|
||||
else:
|
||||
# Update model_config (contains cwd) if changed.
|
||||
try:
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
|
||||
(cwd_json, model_str, state.session_id),
|
||||
)
|
||||
db._conn.commit()
|
||||
db.update_session_meta(state.session_id, cwd_json, model_str)
|
||||
except Exception:
|
||||
logger.debug("Failed to update ACP session metadata", exc_info=True)
|
||||
|
||||
|
||||
@ -441,6 +441,10 @@ def is_local_endpoint(base_url: str) -> bool:
|
||||
# Docker / Podman / Lima internal DNS names (e.g. host.docker.internal)
|
||||
if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES):
|
||||
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
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
|
||||
@ -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:
|
||||
|
||||
```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:
|
||||
@ -40,7 +40,7 @@ It builds and launches the GUI against your existing install — same config, ke
|
||||
|
||||
### 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
|
||||
|
||||
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep). The only thing worth knowing:
|
||||
|
||||
- **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).
|
||||
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ interface SlashItemMetadata extends Record<string, string> {
|
||||
command: string
|
||||
display: string
|
||||
meta: string
|
||||
rawText: string
|
||||
}
|
||||
|
||||
function textValue(value: unknown, fallback = ''): string {
|
||||
@ -91,7 +92,13 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
|
||||
const metadata: SlashItemMetadata = {
|
||||
command,
|
||||
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 {
|
||||
|
||||
@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
@ -1056,7 +1057,19 @@ export function ChatBar({
|
||||
if (queueEdit) {
|
||||
exitQueuedEdit('save')
|
||||
} 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()
|
||||
} else {
|
||||
// Stop button: an explicit interrupt must actually halt the running
|
||||
|
||||
@ -37,6 +37,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$panesFlipped,
|
||||
@ -363,11 +364,10 @@ export function ChatSidebar({
|
||||
return []
|
||||
}
|
||||
|
||||
const needle = trimmedQuery.toLowerCase()
|
||||
const out = new Map<string, SessionInfo>()
|
||||
|
||||
for (const s of sortedSessions) {
|
||||
if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) {
|
||||
if (sessionMatchesSearch(s, trimmedQuery)) {
|
||||
out.set(s.id, s)
|
||||
}
|
||||
}
|
||||
|
||||
@ -158,7 +158,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
Palette,
|
||||
Plus,
|
||||
Settings,
|
||||
Settings2,
|
||||
Sun,
|
||||
Users,
|
||||
Wrench,
|
||||
@ -105,7 +106,18 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: strin
|
||||
tab: 'providers&pview=keys'
|
||||
},
|
||||
{ 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: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
|
||||
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
|
||||
|
||||
108
apps/desktop/src/app/cron/cron-job-actions-menu.tsx
Normal file
108
apps/desktop/src/app/cron/cron-job-actions-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -17,7 +17,6 @@ import { Input } from '@/components/ui/input'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import {
|
||||
createCronJob,
|
||||
type CronJob,
|
||||
@ -29,12 +28,13 @@ import {
|
||||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { AlertTriangle, Clock } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
|
||||
@ -548,54 +548,27 @@ function CronJobRow({
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<IconAction
|
||||
aria-label={isPaused ? 'Resume cron' : 'Pause cron'}
|
||||
disabled={busy}
|
||||
onClick={onPauseResume}
|
||||
title={isPaused ? 'Resume' : 'Pause'}
|
||||
<div className="flex shrink-0 items-center">
|
||||
<CronJobActionsMenu
|
||||
busy={busy}
|
||||
isPaused={isPaused}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onPauseResume={onPauseResume}
|
||||
onTrigger={onTrigger}
|
||||
title={jobTitle(job)}
|
||||
>
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
</IconAction>
|
||||
<IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now">
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
</IconAction>
|
||||
<IconAction aria-label="Edit cron" onClick={onEdit} title="Edit">
|
||||
<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>
|
||||
<CronJobActionsTrigger
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={event => event.stopPropagation()}
|
||||
title={jobTitle(job)}
|
||||
/>
|
||||
</CronJobActionsMenu>
|
||||
</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({
|
||||
actionLabel,
|
||||
description,
|
||||
|
||||
@ -558,6 +558,7 @@ export function DesktopController() {
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
|
||||
@ -8,7 +8,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import {
|
||||
getMessagingPlatforms,
|
||||
type MessagingEnvVarInfo,
|
||||
@ -22,6 +21,8 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
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 { PlatformAvatar } from './platform-icon'
|
||||
@ -109,6 +110,47 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
|
||||
help: 'first, all, or off.',
|
||||
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: {
|
||||
label: 'Slack bot token',
|
||||
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
|
||||
@ -498,7 +540,7 @@ function PlatformDetail({
|
||||
|
||||
<section>
|
||||
<SectionTitle>Required</SectionTitle>
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="mt-3 grid gap-1">
|
||||
{requiredFields.length > 0 ? (
|
||||
requiredFields.map(field => (
|
||||
<MessagingField
|
||||
@ -521,7 +563,7 @@ function PlatformDetail({
|
||||
{optionalFields.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle>Recommended</SectionTitle>
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="mt-3 grid gap-1">
|
||||
{optionalFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
@ -547,7 +589,7 @@ function PlatformDetail({
|
||||
<DisclosureCaret open={showAdvanced} size="0.875rem" />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="mt-3 grid gap-1">
|
||||
{advancedFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
@ -641,48 +683,48 @@ function MessagingField({
|
||||
saving: string | null
|
||||
}) {
|
||||
const copy = fieldCopy(field)
|
||||
const fieldId = `messaging-field-${field.key}`
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor={`messaging-field-${field.key}`}>
|
||||
{copy.label}
|
||||
</label>
|
||||
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="font-mono"
|
||||
id={`messaging-field-${field.key}`}
|
||||
onChange={event => onEdit(field.key, event.target.value)}
|
||||
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
|
||||
type={field.is_password ? 'password' : 'text'}
|
||||
value={edits[field.key] || ''}
|
||||
/>
|
||||
{field.url && (
|
||||
<Tip label="Open docs">
|
||||
<Button asChild size="icon-sm" variant="ghost">
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className={CREDENTIAL_CONTROL_CLASS}
|
||||
id={fieldId}
|
||||
onChange={event => onEdit(field.key, event.target.value)}
|
||||
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
|
||||
type={field.is_password ? 'password' : 'text'}
|
||||
value={edits[field.key] || ''}
|
||||
/>
|
||||
{field.url && (
|
||||
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
|
||||
<a href={field.url} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
{field.is_set && (
|
||||
<Tip label={`Clear ${field.key}`}>
|
||||
)}
|
||||
{field.is_set && (
|
||||
<Button
|
||||
className="size-8 shrink-0"
|
||||
disabled={saving === `clear:${field.key}`}
|
||||
onClick={() => onClear(field.key)}
|
||||
size="icon-sm"
|
||||
title={`Clear ${field.key}`}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
{copy.help && <p className="text-xs leading-5 text-muted-foreground">{copy.help}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={copy.help}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
166
apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
Normal file
166
apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -38,10 +38,11 @@ import {
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages,
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
||||
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types'
|
||||
import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types'
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -78,6 +79,7 @@ interface PromptActionsOptions {
|
||||
branchCurrentSession: () => Promise<boolean>
|
||||
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
|
||||
handleSkinCommand: (arg: string) => string
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
startFreshSessionDraft: () => void
|
||||
@ -142,6 +144,7 @@ export function usePromptActions({
|
||||
branchCurrentSession,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
@ -501,6 +504,50 @@ export function usePromptActions({
|
||||
const renderSlashOutput = (text: string) =>
|
||||
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') {
|
||||
renderSlashOutput(handleSkinCommand(arg))
|
||||
|
||||
@ -601,6 +648,7 @@ export function usePromptActions({
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText
|
||||
|
||||
363
apps/desktop/src/app/settings/credential-key-ui.tsx
Normal file
363
apps/desktop/src/app/settings/credential-key-ui.tsx
Normal 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]
|
||||
}
|
||||
@ -1,16 +1,10 @@
|
||||
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 { Check, Eye, EyeOff, type IconComponent, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type IconComponent } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { asText, includesQuery, redactedValue, withoutKey } from './helpers'
|
||||
import { Pill } from './primitives'
|
||||
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) {
|
||||
return (
|
||||
<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
|
||||
}
|
||||
|
||||
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 {
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
|
||||
|
||||
130
apps/desktop/src/app/settings/env-var-actions-menu.tsx
Normal file
130
apps/desktop/src/app/settings/env-var-actions-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { getNested, providerGroup, setNested } from './helpers'
|
||||
import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('reads and writes nested config paths', () => {
|
||||
@ -21,6 +21,26 @@ describe('settings helpers', () => {
|
||||
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', () => {
|
||||
it('maps a provider env var to its labeled group', () => {
|
||||
expect(providerGroup('XAI_API_KEY')).toBe('xAI')
|
||||
|
||||
@ -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())
|
||||
|
||||
/** 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 withoutKey = <T>(record: Record<string, T>, key: string) => {
|
||||
|
||||
@ -4,7 +4,7 @@ import { useRef } from 'react'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
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 { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@ -17,7 +17,7 @@ import { AppearanceSettings } from './appearance-settings'
|
||||
import { ConfigSettings } from './config-settings'
|
||||
import { SECTIONS } from './constants'
|
||||
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 { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-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
|
||||
// sub-view is deep-linkable and survives a refresh.
|
||||
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
|
||||
const [keysView, setKeysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
|
||||
|
||||
const openProviderView = (view: ProviderView) => {
|
||||
setActiveView('providers')
|
||||
setProviderView(view)
|
||||
}
|
||||
|
||||
const openKeysView = (view: KeysView) => {
|
||||
setActiveView('keys')
|
||||
setKeysView(view)
|
||||
}
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const exportConfig = async () => {
|
||||
@ -130,6 +136,24 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
label="Tools & 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
|
||||
active={activeView === 'mcp'}
|
||||
icon={Wrench}
|
||||
@ -196,7 +220,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
) : activeView === 'providers' ? (
|
||||
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings />
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
|
||||
) : (
|
||||
|
||||
@ -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 { EnvVarRow, useEnvCredentials } from './env-credentials'
|
||||
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
|
||||
import { useEnvCredentials } from './env-credentials'
|
||||
import { asText } from './helpers'
|
||||
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
|
||||
// dedicated Messaging page (and are hidden here via `channel_managed`). This
|
||||
// view covers tool API keys plus server/setting env vars (API server, webhook,
|
||||
// gateway), which fold into the Settings tab.
|
||||
const KEY_TABS = [
|
||||
{ icon: Wrench, id: 'tool', label: 'Tools' },
|
||||
{ icon: Settings2, id: 'setting', label: 'Settings' }
|
||||
] as const
|
||||
// gateway), which fold into the Settings subnav.
|
||||
|
||||
type KeyCategoryId = (typeof KEY_TABS)[number]['id']
|
||||
|
||||
const CATEGORY_LABELS: Record<KeyCategoryId, string> = {
|
||||
setting: 'Settings',
|
||||
tool: 'Tools'
|
||||
// Backend categories that surface under each subnav. Platform credentials use the
|
||||
// `messaging` category but are flagged ``channel_managed`` and configured on
|
||||
// the Messaging page; only gateway-wide ``messaging`` rows (e.g. GATEWAY_PROXY)
|
||||
// appear here alongside ``setting``.
|
||||
const VIEW_CATEGORIES: Record<KeysView, readonly string[]> = {
|
||||
settings: ['setting', 'messaging'],
|
||||
tools: ['tool']
|
||||
}
|
||||
|
||||
// Backend categories that surface under each tab. Server/gateway vars carry the
|
||||
// `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() {
|
||||
export function KeysSettings({ view }: KeysSettingsProps) {
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [activeCategory, setActiveCategory] = useState<KeyCategoryId>('tool')
|
||||
const [openKey, setOpenKey] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setOpenKey(null)
|
||||
}, [view])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
return KEY_TABS.map(t => t.id).flatMap(tab => {
|
||||
const cats = TAB_CATEGORIES[tab]
|
||||
return KEYS_VIEWS.flatMap(v => {
|
||||
const cats = VIEW_CATEGORIES[v]
|
||||
|
||||
const entries = Object.entries(vars)
|
||||
.filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category)))
|
||||
.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])
|
||||
|
||||
// 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) {
|
||||
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 (
|
||||
<SettingsContent>
|
||||
<CategoryTabs active={activeCategory} counts={categoryCounts} onSelect={setActiveCategory} />
|
||||
|
||||
{visible.map(group => (
|
||||
<section className="mb-6" key={group.category}>
|
||||
<div className="grid gap-2">
|
||||
{group.entries.map(([key, info]: [string, EnvVarInfo]) => (
|
||||
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="grid gap-2" key={group.category}>
|
||||
{group.entries.map(([key, info]: [string, EnvVarInfo]) => {
|
||||
const label = credentialRowLabel(key, info)
|
||||
|
||||
return (
|
||||
<CredentialKeyCard
|
||||
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 && (
|
||||
@ -160,3 +88,7 @@ export function KeysSettings() {
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
interface KeysSettingsProps {
|
||||
view: KeysView
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ChangeEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
FEATURED_ID,
|
||||
@ -9,43 +9,25 @@ import {
|
||||
sortProviders
|
||||
} from '@/components/desktop-onboarding-overlay'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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 { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
|
||||
|
||||
import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
|
||||
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 type { EnvRowProps } from './types'
|
||||
|
||||
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
|
||||
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
|
||||
|
||||
export type ProviderView = (typeof PROVIDER_VIEWS)[number]
|
||||
|
||||
const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
|
||||
|
||||
const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
|
||||
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.
|
||||
// Group the env catalog by provider — one ListRow per vendor plus optional
|
||||
// advanced overrides (base URL, region, etc.). Groups without a key field and
|
||||
// the "Other" bucket are skipped.
|
||||
function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] {
|
||||
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))
|
||||
}
|
||||
|
||||
// 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
|
||||
// (`Picker` in desktop-onboarding-overlay): same recommended card, same
|
||||
// provider rows, same "Other providers" disclosure, same OpenRouter quick-key
|
||||
@ -405,7 +165,6 @@ function NoProviderKeys() {
|
||||
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||
// Single-open accordion for the per-provider "advanced options" panels.
|
||||
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
||||
// 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
|
||||
@ -452,7 +211,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
{keyGroups.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
{keyGroups.map(group => (
|
||||
<ProviderKeyCard
|
||||
<ProviderKeyRows
|
||||
expanded={openProvider === group.name}
|
||||
group={group}
|
||||
key={group.name}
|
||||
@ -476,8 +235,6 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
)
|
||||
}
|
||||
|
||||
type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
|
||||
|
||||
interface ProviderKeyGroup {
|
||||
advanced: [string, EnvVarInfo][]
|
||||
description?: string
|
||||
|
||||
@ -3,13 +3,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
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 { notify, notifyError } from '@/store/notifications'
|
||||
import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
|
||||
|
||||
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
|
||||
import { Pill } from './primitives'
|
||||
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{envVar.url && (
|
||||
<Button asChild size="xs" variant="ghost">
|
||||
<a href={envVar.url} rel="noreferrer" target="_blank">
|
||||
Docs
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{isSet && (
|
||||
<Tip label="Reveal value">
|
||||
<Button onClick={() => void handleReveal()} size="icon-xs" variant="ghost">
|
||||
{revealed !== null ? <EyeOff /> : <Eye />}
|
||||
</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>
|
||||
{!editing && (
|
||||
<EnvVarActionsMenu
|
||||
clearDisabled={busy}
|
||||
docsUrl={envVar.url}
|
||||
isRevealed={revealed !== null}
|
||||
isSet={isSet}
|
||||
label={envVar.key}
|
||||
onClear={() => void handleClear()}
|
||||
onEdit={() => setEditing(true)}
|
||||
onReveal={() => void handleReveal()}
|
||||
>
|
||||
<EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
|
||||
</EnvVarActionsMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSet && revealed !== null && (
|
||||
|
||||
@ -74,6 +74,17 @@ describe('SkillsView toolset management', () => {
|
||||
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 () => {
|
||||
await renderSkills()
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PAGE_INSET_X } from '../layout-constants'
|
||||
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 type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
@ -52,14 +52,17 @@ function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[]
|
||||
return true
|
||||
}
|
||||
|
||||
const label = toolsetDisplayLabel(toolset)
|
||||
|
||||
return (
|
||||
includesQuery(toolset.name, q) ||
|
||||
includesQuery(label, q) ||
|
||||
includesQuery(toolset.label, q) ||
|
||||
includesQuery(toolset.description, 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'> {
|
||||
@ -167,10 +170,10 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
notify({
|
||||
kind: 'success',
|
||||
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) {
|
||||
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
|
||||
notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`)
|
||||
} finally {
|
||||
setSavingToolset(null)
|
||||
}
|
||||
@ -264,7 +267,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<div>
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
const label = toolsetDisplayLabel(toolset)
|
||||
const expanded = expandedToolset === toolset.name
|
||||
|
||||
return (
|
||||
|
||||
@ -25,6 +25,14 @@ export interface SlashExecResponse {
|
||||
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 {
|
||||
type: 'exec' | 'plugin'
|
||||
output?: string
|
||||
|
||||
@ -438,7 +438,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
|
||||
s.thread.isRunning &&
|
||||
s.message.status?.type === 'running' &&
|
||||
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')
|
||||
)
|
||||
|
||||
|
||||
@ -2,11 +2,23 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
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 { notify, notifyError } from '@/store/notifications'
|
||||
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
|
||||
// exited during startup, bootstrap latched, …). Without this the app shell
|
||||
@ -18,6 +30,7 @@ export function BootFailureOverlay() {
|
||||
const [busy, setBusy] = useState<BusyAction>(null)
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null)
|
||||
|
||||
const visible = Boolean(boot.error) && !boot.running
|
||||
// While first-run onboarding owns the picker/flow we let it surface its own
|
||||
@ -36,6 +49,59 @@ export function BootFailureOverlay() {
|
||||
.catch(() => undefined)
|
||||
}, [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) {
|
||||
return null
|
||||
}
|
||||
@ -59,8 +125,44 @@ export function BootFailureOverlay() {
|
||||
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 label = signInLabel(remoteReauth)
|
||||
|
||||
return (
|
||||
<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">
|
||||
@ -69,10 +171,13 @@ export function BootFailureOverlay() {
|
||||
<AlertTriangle className="size-5" />
|
||||
</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)">
|
||||
The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your
|
||||
chats or settings.
|
||||
{remoteReauth
|
||||
? '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>
|
||||
</div>
|
||||
</div>
|
||||
@ -84,14 +189,23 @@ export function BootFailureOverlay() {
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
|
||||
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
||||
Retry
|
||||
</Button>
|
||||
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
|
||||
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
|
||||
Repair install
|
||||
</Button>
|
||||
{remoteReauth ? (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void signInRemote()}>
|
||||
{busy === 'signin' ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
|
||||
{label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
|
||||
{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">
|
||||
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Use local gateway
|
||||
@ -102,7 +216,9 @@ export function BootFailureOverlay() {
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
99
apps/desktop/src/components/boot-failure-reauth.test.ts
Normal file
99
apps/desktop/src/components/boot-failure-reauth.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
67
apps/desktop/src/components/boot-failure-reauth.ts
Normal file
67
apps/desktop/src/components/boot-failure-reauth.ts
Normal 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'}`
|
||||
}
|
||||
58
apps/desktop/src/lib/session-search.test.ts
Normal file
58
apps/desktop/src/lib/session-search.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
19
apps/desktop/src/lib/session-search.ts
Normal file
19
apps/desktop/src/lib/session-search.ts
Normal 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
7
cli.py
@ -7166,7 +7166,11 @@ class HermesCLI:
|
||||
except Exception:
|
||||
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:
|
||||
self._session_db.create_session(
|
||||
session_id=new_session_id,
|
||||
@ -7175,6 +7179,7 @@ class HermesCLI:
|
||||
model_config={
|
||||
"max_iterations": self.max_turns,
|
||||
"reasoning_config": self.reasoning_config,
|
||||
"_branched_from": parent_session_id,
|
||||
},
|
||||
parent_session_id=parent_session_id,
|
||||
)
|
||||
|
||||
@ -967,14 +967,35 @@ def _media_delivery_denied_paths() -> List[Path]:
|
||||
|
||||
|
||||
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():
|
||||
try:
|
||||
resolved_denied = denied.expanduser().resolve(strict=False)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
if _path_is_within(resolved, resolved_denied) or resolved == resolved_denied:
|
||||
return True
|
||||
if not (_path_is_within(resolved, resolved_denied) or resolved == resolved_denied):
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -2799,11 +2799,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
def _markdown_to_html(self, text: str) -> str:
|
||||
"""Convert Markdown to Matrix-compatible HTML (org.matrix.custom.html).
|
||||
|
||||
Uses the ``markdown`` library when available (installed with the
|
||||
``matrix`` extra). Falls back to a comprehensive regex converter
|
||||
that handles fenced code blocks, inline code, headers, bold,
|
||||
italic, strikethrough, links, blockquotes, lists, and horizontal
|
||||
rules — everything the Matrix HTML spec allows.
|
||||
Uses the ``markdown`` library (a core dependency) when available.
|
||||
Falls back to a comprehensive regex converter that handles fenced
|
||||
code blocks, inline code, headers, bold, italic, strikethrough,
|
||||
links, blockquotes, lists, and horizontal rules — everything the
|
||||
Matrix HTML spec allows.
|
||||
"""
|
||||
try:
|
||||
import markdown as _md
|
||||
|
||||
@ -8524,18 +8524,14 @@ class GatewayRunner:
|
||||
logger.debug("goal continuation hook failed: %s", _goal_exc)
|
||||
return _agent_result
|
||||
finally:
|
||||
# If _run_agent replaced the sentinel with a real agent and
|
||||
# then cleaned it up, this is a no-op. If we exited early
|
||||
# (exception, command fallthrough, etc.) the sentinel must
|
||||
# not linger or the session would be permanently locked out.
|
||||
if self._running_agents.get(_quick_key) is _AGENT_PENDING_SENTINEL:
|
||||
self._release_running_agent_state(_quick_key)
|
||||
else:
|
||||
# Agent path already cleaned _running_agents; make sure
|
||||
# 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)
|
||||
# Unconditional release covers every exit path. _release_running_agent_state
|
||||
# is idempotent (pop-on-absent is harmless) and, called without a
|
||||
# run_generation guard, always clears the slot regardless of which
|
||||
# generation it holds. This evicts the zombie left when session_reset
|
||||
# bumps the generation (N -> N+1) mid-flight: gen-N's guarded release
|
||||
# inside _run_agent returns False, and the old sentinel-only check here
|
||||
# missed the leftover real agent — locking the session out forever (#28686).
|
||||
self._release_running_agent_state(_quick_key)
|
||||
|
||||
async def _prepare_inbound_message_text(
|
||||
self,
|
||||
@ -10032,6 +10028,12 @@ class GatewayRunner:
|
||||
# Get existing session key
|
||||
session_key = self._session_key_for_source(source)
|
||||
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
|
||||
# expiring session id before reset_session() rotates it.
|
||||
@ -13954,12 +13956,17 @@ class GatewayRunner:
|
||||
|
||||
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:
|
||||
self._session_db.create_session(
|
||||
session_id=new_session_id,
|
||||
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_config={"_branched_from": parent_session_id},
|
||||
parent_session_id=parent_session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@ -530,7 +530,19 @@ class GatewayStreamConsumer:
|
||||
if split_at < _safe_limit // 2:
|
||||
split_at = _safe_limit
|
||||
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:
|
||||
# Edit failed (or backed off due to flood control)
|
||||
# while attempting to split an oversized message.
|
||||
|
||||
@ -1322,38 +1322,10 @@ def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]:
|
||||
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]:
|
||||
"""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``.
|
||||
"""
|
||||
"""Return the currently active provider ID from 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:
|
||||
@ -1575,14 +1547,10 @@ def resolve_provider(
|
||||
if explicit_api_key or explicit_base_url:
|
||||
return "openrouter"
|
||||
|
||||
# Check auth store for an active OAuth provider. In profile mode this
|
||||
# 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.
|
||||
# Check auth store for an active OAuth provider
|
||||
try:
|
||||
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:
|
||||
status = get_auth_status(active)
|
||||
if status.get("logged_in"):
|
||||
|
||||
@ -5958,6 +5958,11 @@ def set_config_value(key: str, value: str):
|
||||
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
||||
"terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
|
||||
"terminal.docker_env": "TERMINAL_DOCKER_ENV",
|
||||
# JSON-valued keys (terminal_tool parses these via json.loads). The user
|
||||
# passes JSON on the CLI, so str(value) below already yields valid JSON —
|
||||
# same as terminal.docker_env. cli.py and gateway/run.py bridge these too.
|
||||
"terminal.docker_volumes": "TERMINAL_DOCKER_VOLUMES",
|
||||
"terminal.docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
|
||||
# terminal.cwd intentionally excluded — CLI resolves at runtime,
|
||||
# gateway bridges it in gateway/run.py. Persisting to .env causes
|
||||
# stale values to poison child processes.
|
||||
|
||||
@ -8039,7 +8039,10 @@ def _update_via_zip(args):
|
||||
# may point to a Python without FTS5. Rebuild it so the new managed
|
||||
# uv provides a fresh interpreter with FTS5 guaranteed.
|
||||
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"]
|
||||
if not uv_bin:
|
||||
@ -10224,7 +10227,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
return
|
||||
print("✗ Not a git repository. Please reinstall:")
|
||||
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)
|
||||
|
||||
@ -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
|
||||
# uv provides a fresh interpreter with FTS5 guaranteed.
|
||||
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"]
|
||||
if not uv_bin:
|
||||
|
||||
@ -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
|
||||
success.
|
||||
|
||||
On Windows, ``shutil.rmtree(..., ignore_errors=True)`` can silently leave
|
||||
the venv directory partially intact when another process is holding an
|
||||
open handle to a file inside it (typical culprits: a running
|
||||
``hermes.exe`` REPL, the gateway, AV scanners). If we don't notice that
|
||||
and just call ``uv venv``, uv refuses with
|
||||
``Caused by: A directory already exists at: venv`` and the *whole
|
||||
update* falls back to installing on top of the stale venv — which has
|
||||
historically produced partial installs where a freshly added dependency
|
||||
(e.g. ``pathspec``) silently fails to land. Retry with ``--clear`` to
|
||||
force uv past that condition before giving up.
|
||||
The old venv is moved aside *atomically* (``os.replace`` to ``<venv>.old``)
|
||||
before recreating — never deleted in place. On Windows a still-running
|
||||
``hermes.exe`` (gateway/desktop) holds ``venv\\Scripts\\python.exe`` open;
|
||||
``shutil.rmtree(ignore_errors=True)`` would delete everything it *can*
|
||||
(site-packages, certifi's cert bundle) and silently leave a half-gutted
|
||||
venv that the following ``uv venv`` then refuses to overwrite ("directory
|
||||
already exists") — bricking the install with no recovery (every later HTTPS
|
||||
call dies with ``FileNotFoundError`` for the missing cert bundle).
|
||||
``--clear`` alone does not fix this: when the locked interpreter is *inside*
|
||||
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():
|
||||
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]:
|
||||
return subprocess.run(
|
||||
[uv_bin, "venv", str(venv_dir), "--python", python_version, *extra_args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
result = subprocess.run(
|
||||
[uv_bin, "venv", str(venv_dir), "--python", python_version, "--clear"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
result = _run_uv_venv([])
|
||||
|
||||
# If uv refused because the directory still exists (rmtree above was
|
||||
# blocked by an open file handle, common on Windows), retry with
|
||||
# --clear so uv overwrites it. Match on stderr because uv's exit code
|
||||
# alone doesn't distinguish "dir exists" from real failures.
|
||||
if result.returncode != 0 and "already exists" in (result.stderr or "").lower():
|
||||
print(" → venv dir not fully removed (likely an open file handle); retrying with --clear...")
|
||||
result = _run_uv_venv(["--clear"])
|
||||
def _restore_backup() -> None:
|
||||
if backup is not None and backup.exists():
|
||||
shutil.rmtree(venv_dir, ignore_errors=True)
|
||||
try:
|
||||
os.replace(backup, venv_dir)
|
||||
print(" ↩ Restored previous venv after failed rebuild.")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if result.returncode == 0:
|
||||
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(
|
||||
[str(venv_python), "--version"],
|
||||
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})")
|
||||
return True
|
||||
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)
|
||||
print(f" ✗ venv rebuild failed: {result.stderr.strip()}")
|
||||
return False
|
||||
|
||||
@ -225,13 +225,15 @@ def _probe_single_server(
|
||||
server = await asyncio.wait_for(
|
||||
_connect_server(name, config), timeout=connect_timeout
|
||||
)
|
||||
for t in server._tools:
|
||||
desc = getattr(t, "description", "") or ""
|
||||
# Truncate long descriptions for display
|
||||
if len(desc) > 80:
|
||||
desc = desc[:77] + "..."
|
||||
tools_found.append((t.name, desc))
|
||||
await server.shutdown()
|
||||
try:
|
||||
for t in server._tools:
|
||||
desc = getattr(t, "description", "") or ""
|
||||
# Truncate long descriptions for display
|
||||
if len(desc) > 80:
|
||||
desc = desc[:77] + "..."
|
||||
tools_found.append((t.name, desc))
|
||||
finally:
|
||||
await server.shutdown()
|
||||
|
||||
try:
|
||||
_run_on_mcp_loop(_probe(), timeout=connect_timeout + 10)
|
||||
|
||||
@ -52,6 +52,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("deepseek/deepseek-v4-flash", ""),
|
||||
# Qwen
|
||||
("qwen/qwen3.7-max", ""),
|
||||
("qwen/qwen3.7-plus", ""),
|
||||
("qwen/qwen3.6-35b-a3b", ""),
|
||||
# MoonshotAI
|
||||
("moonshotai/kimi-k2.6", "recommended"),
|
||||
@ -169,6 +170,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"deepseek/deepseek-v4-flash",
|
||||
# Qwen
|
||||
"qwen/qwen3.7-max",
|
||||
"qwen/qwen3.7-plus",
|
||||
"qwen/qwen3.6-35b-a3b",
|
||||
# MoonshotAI
|
||||
"moonshotai/kimi-k2.6",
|
||||
@ -588,7 +590,8 @@ def union_with_portal_free_recommendations(
|
||||
pair where:
|
||||
|
||||
* 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"}``
|
||||
entry for any free recommendation missing from the live pricing
|
||||
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)
|
||||
seen = set(augmented_ids)
|
||||
# Prepend Portal free recommendations that aren't already curated, so
|
||||
# they appear first in the picker.
|
||||
# Append Portal free recommendations that aren't already curated, so the
|
||||
# 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]
|
||||
if new_ones:
|
||||
augmented_ids = new_ones + augmented_ids
|
||||
augmented_ids = augmented_ids + new_ones
|
||||
|
||||
return (augmented_ids, augmented_pricing)
|
||||
|
||||
@ -653,7 +656,8 @@ def union_with_portal_paid_recommendations(
|
||||
``(model_ids, pricing)`` pair where:
|
||||
|
||||
* 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 entries for paid models. Live pricing is fetched separately
|
||||
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)
|
||||
seen = set(augmented_ids)
|
||||
# Prepend Portal paid recommendations that aren't already curated, so
|
||||
# the Portal-blessed picks surface first in the picker.
|
||||
# Append Portal paid recommendations that aren't already curated, so the
|
||||
# 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]
|
||||
if new_ones:
|
||||
augmented_ids = new_ones + augmented_ids
|
||||
augmented_ids = augmented_ids + new_ones
|
||||
|
||||
return (augmented_ids, dict(pricing))
|
||||
|
||||
|
||||
@ -82,6 +82,22 @@ CONFIGURABLE_TOOLSETS = [
|
||||
("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.
|
||||
# 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.
|
||||
|
||||
@ -734,9 +734,9 @@ def run_uninstall(args):
|
||||
print()
|
||||
print("To reinstall later with your existing settings:")
|
||||
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:
|
||||
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()
|
||||
|
||||
if _is_windows():
|
||||
|
||||
@ -14,11 +14,14 @@ from contextlib import asynccontextmanager
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
@ -26,6 +29,7 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
@ -557,6 +561,14 @@ class MessagingPlatformUpdate(BaseModel):
|
||||
clear_env: List[str] = []
|
||||
|
||||
|
||||
class TelegramOnboardingStart(BaseModel):
|
||||
bot_name: Optional[str] = None
|
||||
|
||||
|
||||
class TelegramOnboardingApply(BaseModel):
|
||||
allowed_user_ids: List[str]
|
||||
|
||||
|
||||
class AudioTranscriptionRequest(BaseModel):
|
||||
data_url: str
|
||||
mime_type: Optional[str] = None
|
||||
@ -1720,14 +1732,15 @@ async def get_profiles_sessions(
|
||||
|
||||
@app.get("/api/sessions/search")
|
||||
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``.
|
||||
Auto-compression rotates a conversation onto a fresh session id (and leaves
|
||||
the old segment's messages in the FTS index), so one logical chat can own
|
||||
many ``sessions`` rows that all match the same query. Branches also use
|
||||
``parent_session_id``, but they are real alternate conversations; don't
|
||||
collapse branch-specific hits back into the parent.
|
||||
Direct session-id matches are surfaced first, then FTS message-content
|
||||
matches. Results are deduped by compression lineage, not by raw
|
||||
``session_id``. Auto-compression rotates a conversation onto a fresh
|
||||
session id (and leaves the old segment's messages in the FTS index), so one
|
||||
logical chat can own many ``sessions`` rows that all match the same query.
|
||||
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():
|
||||
return {"results": []}
|
||||
@ -1735,21 +1748,7 @@ async def search_sessions(q: str = "", limit: int = 20):
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
# 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(limit * 5, 50)
|
||||
matches = db.search_messages(query=prefix_query, limit=fetch_limit)
|
||||
safe_limit = max(1, min(int(limit or 20), 100))
|
||||
|
||||
# Walk parent_session_id to the compression root, memoized so a
|
||||
# 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
|
||||
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 = {}
|
||||
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)
|
||||
if root in seen:
|
||||
continue
|
||||
seen[root] = {
|
||||
"session_id": lineage_tip(root),
|
||||
"lineage_root": root,
|
||||
"snippet": m.get("snippet", ""),
|
||||
"role": m.get("role"),
|
||||
"source": m.get("source"),
|
||||
"model": m.get("model"),
|
||||
"session_started": m.get("session_started"),
|
||||
}
|
||||
if len(seen) >= limit:
|
||||
if root in seen or len(seen) >= safe_limit:
|
||||
return
|
||||
payload = dict(payload)
|
||||
payload["session_id"] = lineage_tip(root)
|
||||
payload["lineage_root"] = root
|
||||
seen[root] = payload
|
||||
|
||||
# Direct ID matches first: users often paste a session id from CLI,
|
||||
# logs, or another Hermes surface. FTS can't find those unless the
|
||||
# id happens to appear in message text. search_sessions_by_id is
|
||||
# SQL-bounded, so this stays cheap even with thousands of sessions.
|
||||
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
|
||||
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())}
|
||||
finally:
|
||||
db.close()
|
||||
@ -2908,18 +2954,66 @@ def _channel_managed_env_keys() -> frozenset[str]:
|
||||
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(
|
||||
platform_id: str, plugin_entry: Any | None = None
|
||||
) -> dict[str, Any]:
|
||||
override = _PLATFORM_OVERRIDES.get(platform_id, {})
|
||||
|
||||
if "env_vars" in override:
|
||||
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))
|
||||
env_vars = _merge_platform_env_vars(platform_id, override, plugin_entry)
|
||||
|
||||
if "required_env" in override:
|
||||
required_env = tuple(override["required_env"])
|
||||
@ -3077,6 +3171,329 @@ def _write_platform_enabled(platform_id: str, enabled: bool) -> None:
|
||||
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")
|
||||
async def get_messaging_platforms():
|
||||
env_on_disk = load_env()
|
||||
@ -6771,6 +7188,7 @@ async def get_toolsets():
|
||||
_get_effective_configurable_toolsets,
|
||||
_get_platform_tools,
|
||||
_toolset_has_keys,
|
||||
gui_toolset_label,
|
||||
)
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
@ -6788,7 +7206,9 @@ async def get_toolsets():
|
||||
tools = []
|
||||
is_enabled = name in enabled_toolsets
|
||||
result.append({
|
||||
"name": name, "label": label, "description": desc,
|
||||
"name": name,
|
||||
"label": gui_toolset_label(label),
|
||||
"description": desc,
|
||||
"enabled": is_enabled,
|
||||
"available": is_enabled,
|
||||
"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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import re
|
||||
|
||||
# PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native
|
||||
# Windows the import raises; catch and leave PtyBridge=None so the rest of
|
||||
# the dashboard (sessions, jobs, metrics, config editor) still loads and the
|
||||
|
||||
151
hermes_state.py
151
hermes_state.py
@ -615,17 +615,27 @@ class SessionDB:
|
||||
)
|
||||
|
||||
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
|
||||
frames that no other connection currently needs. Keeps the WAL
|
||||
from growing unbounded when many processes hold persistent
|
||||
Flushes committed WAL frames back into the main DB file and
|
||||
truncates the WAL file to zero bytes. Keeps the WAL from
|
||||
growing unbounded when many processes hold persistent
|
||||
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:
|
||||
with self._lock:
|
||||
result = self._conn.execute(
|
||||
"PRAGMA wal_checkpoint(PASSIVE)"
|
||||
"PRAGMA wal_checkpoint(TRUNCATE)"
|
||||
).fetchone()
|
||||
if result and result[1] > 0:
|
||||
logger.debug(
|
||||
@ -638,13 +648,13 @@ class SessionDB:
|
||||
def close(self):
|
||||
"""Close the database connection.
|
||||
|
||||
Attempts a PASSIVE WAL checkpoint first so that exiting processes
|
||||
help keep the WAL file from growing unbounded.
|
||||
Attempts a TRUNCATE WAL checkpoint first so that exiting processes
|
||||
help shrink the WAL file.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._conn:
|
||||
try:
|
||||
self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except Exception:
|
||||
pass
|
||||
self._conn.close()
|
||||
@ -1124,6 +1134,24 @@ class SessionDB:
|
||||
return None
|
||||
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:
|
||||
"""Store the full assembled system prompt snapshot."""
|
||||
@ -1585,6 +1613,7 @@ class SessionDB:
|
||||
order_by_last_active: bool = False,
|
||||
include_archived: bool = False,
|
||||
archived_only: bool = False,
|
||||
id_query: str = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions with preview (first user message) and last active timestamp.
|
||||
|
||||
@ -1617,13 +1646,23 @@ class SessionDB:
|
||||
params = []
|
||||
|
||||
if not include_children:
|
||||
# Show root sessions and branch sessions (whose parent ended with
|
||||
# end_reason='branched' before the child was created), while still
|
||||
# hiding sub-agent runs and compression continuations (which also
|
||||
# carry a parent_session_id but were spawned while the parent was
|
||||
# still live — i.e., started_at < parent.ended_at).
|
||||
# Show root sessions and branch sessions, while still hiding
|
||||
# sub-agent runs and compression continuations (which also carry a
|
||||
# parent_session_id but were spawned while the parent was 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(
|
||||
"(s.parent_session_id IS NULL"
|
||||
" OR json_extract(s.model_config, '$._branched_from') IS NOT NULL"
|
||||
" OR EXISTS (SELECT 1 FROM sessions p"
|
||||
" WHERE p.id = s.parent_session_id"
|
||||
" AND p.end_reason = 'branched'"
|
||||
@ -1646,6 +1685,16 @@ class SessionDB:
|
||||
where_clauses.append("s.archived = 0")
|
||||
|
||||
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:
|
||||
# Compute effective_last_active by walking each surfaced session's
|
||||
# compression-continuation chain forward in SQL and taking the MAX
|
||||
@ -1658,6 +1707,28 @@ class SessionDB:
|
||||
# compression-continuation edges using the same criteria as
|
||||
# get_compression_tip (parent.end_reason='compression' AND
|
||||
# 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"""
|
||||
WITH RECURSIVE chain(root_id, cur_id) AS (
|
||||
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
|
||||
FROM sessions s
|
||||
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
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
# WHERE params apply twice (CTE seed + outer select).
|
||||
params = params + params + [limit, offset]
|
||||
# WHERE params apply twice (CTE seed + outer select); the id filter
|
||||
# only applies to the outer select.
|
||||
params = params + params + id_params + [limit, offset]
|
||||
else:
|
||||
query = f"""
|
||||
SELECT s.*,
|
||||
@ -3027,6 +3099,53 @@ class SessionDB:
|
||||
|
||||
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(
|
||||
self,
|
||||
source: str = None,
|
||||
|
||||
@ -21,7 +21,7 @@ let
|
||||
|
||||
# Single npm deps fetch from the workspace root lockfile.
|
||||
# All workspace packages share this derivation.
|
||||
npmDepsHash = "sha256-2CoB0uUc8Pf9iNR0I1EzVqgL89B5sADnC9sxGah8ndU=";
|
||||
npmDepsHash = "sha256-T9UtpXgBCl/GywDZyrvG4a69RkV8oD6p1UOT7GPgAS0=";
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
|
||||
221
package-lock.json
generated
221
package-lock.json
generated
@ -143,6 +143,9 @@
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5",
|
||||
"wait-on": "^9.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/@nous-research/ui": {
|
||||
@ -8353,6 +8356,16 @@
|
||||
"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": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@ -8985,7 +8998,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -8995,7 +9007,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@ -9791,6 +9802,15 @@
|
||||
"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": {
|
||||
"version": "1.0.30001787",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
|
||||
@ -10061,7 +10081,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@ -10074,7 +10093,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
@ -11083,6 +11110,12 @@
|
||||
"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": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
|
||||
@ -11542,7 +11575,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
@ -12704,7 +12736,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@ -14133,7 +14164,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -16823,6 +16853,15 @@
|
||||
"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": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||
@ -16905,7 +16944,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -17020,6 +17058,15 @@
|
||||
"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": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
|
||||
@ -17262,6 +17309,141 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||
@ -17985,7 +18167,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -18000,6 +18181,12 @@
|
||||
"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": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||
@ -18485,6 +18672,12 @@
|
||||
"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": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
@ -18915,7 +19108,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@ -19042,7 +19234,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@ -20930,6 +21121,12 @@
|
||||
"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": {
|
||||
"version": "1.1.20",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||
@ -21972,6 +22169,7 @@
|
||||
"leva": "^0.10.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.38.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.1",
|
||||
@ -21982,6 +22180,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
|
||||
@ -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:
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if n < 1024:
|
||||
@ -226,6 +253,14 @@ def dry_run() -> Tuple[List[Dict], List[Dict]]:
|
||||
cat = item["category"]
|
||||
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":
|
||||
auto.append(item)
|
||||
elif cat == "temp" and age > 7:
|
||||
@ -269,6 +304,28 @@ def quick() -> Dict[str, Any]:
|
||||
|
||||
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 = (
|
||||
cat == "test"
|
||||
or (cat == "temp" and age > 7)
|
||||
|
||||
@ -627,9 +627,9 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
||||
logger.warning("OpenViking session commit failed: %s", e)
|
||||
|
||||
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]
|
||||
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(
|
||||
self,
|
||||
|
||||
@ -9,6 +9,7 @@ xiaomi = ProviderProfile(
|
||||
env_vars=("XIAOMI_API_KEY",),
|
||||
base_url="https://api.xiaomimimo.com/v1",
|
||||
supports_health_check=False, # /v1/models returns 401 even with valid key
|
||||
supports_vision=True, # mimo-v2-omni is vision-capable
|
||||
)
|
||||
|
||||
register_provider(xiaomi)
|
||||
|
||||
@ -1390,7 +1390,7 @@ class GoogleChatAdapter(BasePlatformAdapter):
|
||||
if arg == "start":
|
||||
if not oauth_helper._client_secret_path().exists():
|
||||
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."
|
||||
)
|
||||
return True
|
||||
|
||||
@ -50,10 +50,8 @@ Token storage layout
|
||||
``${HERMES_HOME}/google_chat_user_oauth_pending/<sanitized_email>.json``
|
||||
- Legacy pending state:
|
||||
``${HERMES_HOME}/google_chat_user_oauth_pending.json``
|
||||
- Shared OAuth client (one per host, anchored at the default Hermes root so
|
||||
every profile sees it; a profile-local copy under ``${HERMES_HOME}`` wins
|
||||
when present):
|
||||
``<default-root>/google_chat_user_client_secret.json`` (default ``~/.hermes``)
|
||||
- OAuth client secret (profile-scoped — each profile registers its own):
|
||||
``${HERMES_HOME}/google_chat_user_client_secret.json``
|
||||
"""
|
||||
|
||||
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
|
||||
# profile (e.g. tests can override via HERMES_HOME=/tmp/...).
|
||||
try:
|
||||
from hermes_constants import (
|
||||
display_hermes_home,
|
||||
get_default_hermes_root,
|
||||
get_hermes_home,
|
||||
)
|
||||
from hermes_constants import display_hermes_home, get_hermes_home
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
# Fallback for environments where hermes_constants isn't importable
|
||||
# (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()
|
||||
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:
|
||||
home = get_hermes_home()
|
||||
try:
|
||||
@ -164,24 +140,7 @@ def _token_path(email: Optional[str] = None) -> Path:
|
||||
|
||||
|
||||
def _client_secret_path() -> Path:
|
||||
"""Path to the shared OAuth client secret (one per host).
|
||||
|
||||
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"
|
||||
return _hermes_home() / "google_chat_user_client_secret.json"
|
||||
|
||||
|
||||
def _pending_auth_path(email: Optional[str] = None) -> Path:
|
||||
|
||||
@ -56,6 +56,15 @@ class ProviderProfile:
|
||||
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
|
||||
|
||||
# ── 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 ─────────────────────────────────────────
|
||||
# fallback_models: curated list shown in /model picker when live fetch fails.
|
||||
# Only agentic models that support tool calling should appear here.
|
||||
|
||||
@ -61,6 +61,16 @@ dependencies = [
|
||||
"prompt_toolkit==3.0.52",
|
||||
# Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).
|
||||
"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)
|
||||
"PyJWT[crypto]==2.12.1", # CVE-2026-32597
|
||||
# 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"]
|
||||
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"]
|
||||
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-controlled callback endpoints, so we use defusedxml (drop-in
|
||||
# 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.
|
||||
"hermes-agent[cron]",
|
||||
"hermes-agent[cli]",
|
||||
"hermes-agent[dev]",
|
||||
"hermes-agent[pty]",
|
||||
"hermes-agent[mcp]",
|
||||
"hermes-agent[homeassistant]",
|
||||
|
||||
@ -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
|
||||
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 ============================================================================
|
||||
|
||||
echo.
|
||||
@ -16,12 +16,12 @@ echo Hermes Agent Installer
|
||||
echo Launching PowerShell installer...
|
||||
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 (
|
||||
echo.
|
||||
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.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
# Uses uv for fast Python provisioning and package management.
|
||||
#
|
||||
# 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:
|
||||
# .\install.ps1 -NoVenv -SkipSetup
|
||||
@ -1138,7 +1138,7 @@ function Install-Repository {
|
||||
Write-Info "Trying SSH clone..."
|
||||
$env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5"
|
||||
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 }
|
||||
} catch { }
|
||||
$env:GIT_SSH_COMMAND = $null
|
||||
@ -1147,7 +1147,7 @@ function Install-Repository {
|
||||
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
|
||||
Write-Info "SSH failed, trying HTTPS..."
|
||||
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 }
|
||||
} catch { }
|
||||
}
|
||||
@ -2844,7 +2844,7 @@ try {
|
||||
Write-Err "Installation failed: $_"
|
||||
Write-Host ""
|
||||
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 ""
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
# Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux.
|
||||
#
|
||||
# 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:
|
||||
# curl -fsSL ... | bash -s -- --no-venv --skip-setup
|
||||
@ -451,7 +451,7 @@ detect_os() {
|
||||
OS="windows"
|
||||
DISTRO="windows"
|
||||
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
|
||||
;;
|
||||
*)
|
||||
@ -1126,12 +1126,12 @@ clone_repo() {
|
||||
# so SSH fails fast instead of hanging when no key is configured.
|
||||
log_info "Trying SSH clone..."
|
||||
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"
|
||||
else
|
||||
rm -rf "$INSTALL_DIR" 2>/dev/null # Clean up partial SSH clone
|
||||
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"
|
||||
else
|
||||
log_error "Failed to clone repository"
|
||||
|
||||
@ -45,8 +45,11 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"dirtyren@users.noreply.github.com": "dirtyren",
|
||||
"zhaolei.vc@bytedance.com": "zhaoleibd",
|
||||
"jeffrobodie@gmail.com": "jeffrobodie-glitch",
|
||||
"kyssta-exe@users.noreply.github.com": "kyssta-exe",
|
||||
"ali.zakaee.1997@gmail.com": "ITheEqualizer",
|
||||
"copii.list@gmail.com": "stremtec",
|
||||
"solaiagent@gmail.com": "solaitken",
|
||||
"cryptoworlldz@gmail.com": "worlldz",
|
||||
@ -63,6 +66,8 @@ AUTHOR_MAP = {
|
||||
"david.gutowsky@gmail.com": "davidgut1982",
|
||||
"drpelagik@gmail.com": "SeaXen",
|
||||
"lengr@users.noreply.github.com": "LengR",
|
||||
"Kewe63@users.noreply.github.com": "Kewe63",
|
||||
"kewe.3217@gmail.com": "Kewe63",
|
||||
"17255546+CharZhou@users.noreply.github.com": "CharZhou",
|
||||
"metalclaudbot@gmail.com": "HashClawAI",
|
||||
"tonybear55665566@gmail.com": "TonyPepeBear",
|
||||
|
||||
@ -35,7 +35,7 @@ People use Hermes for software development, research, system administration, dat
|
||||
|
||||
```bash
|
||||
# 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)
|
||||
hermes
|
||||
|
||||
201
tests/acp/test_session_db_private_access.py
Normal file
201
tests/acp/test_session_db_private_access.py
Normal 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"
|
||||
)
|
||||
@ -98,6 +98,16 @@ class TestIsLocalEndpoint:
|
||||
def test_container_dns_names(self, url):
|
||||
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", [
|
||||
"https://api.openai.com",
|
||||
"https://openrouter.ai/api",
|
||||
|
||||
@ -16,7 +16,6 @@ import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
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):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
src = tmp_path / "client_secret.json"
|
||||
|
||||
@ -954,6 +954,120 @@ class TestMediaDeliveryDefaultMode:
|
||||
out = BasePlatformAdapter.filter_local_delivery_paths([str(notes)])
|
||||
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
|
||||
|
||||
@ -228,3 +228,54 @@ class TestSessionDbCloseOnShutdown:
|
||||
|
||||
flaky_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
|
||||
|
||||
@ -17,18 +17,12 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_auth_store(
|
||||
pool: dict | None = None,
|
||||
providers: dict | None = None,
|
||||
active_provider: str | None = None,
|
||||
) -> dict:
|
||||
def _make_auth_store(pool: dict | None = None, providers: dict | None = None) -> dict:
|
||||
store: dict = {"version": 1}
|
||||
if pool is not None:
|
||||
store["credential_pool"] = pool
|
||||
if providers is not None:
|
||||
store["providers"] = providers
|
||||
if active_provider is not None:
|
||||
store["active_provider"] = active_provider
|
||||
return store
|
||||
|
||||
|
||||
@ -456,101 +450,3 @@ def test_write_credential_pool_targets_profile_not_global(profile_env):
|
||||
|
||||
# Subsequent read returns profile (shadows global).
|
||||
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"
|
||||
|
||||
@ -8,6 +8,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
pwd = pytest.importorskip("pwd")
|
||||
grp = pytest.importorskip("grp")
|
||||
|
||||
import hermes_cli.gateway as gateway_cli
|
||||
from gateway import status
|
||||
@ -1331,7 +1332,6 @@ class TestSystemServiceIdentityRootHandling:
|
||||
|
||||
def test_explicit_root_is_allowed(self, monkeypatch):
|
||||
"""When root is explicitly passed via --run-as-user root, allow it."""
|
||||
import grp
|
||||
|
||||
root_info = pwd.getpwnam("root")
|
||||
root_group = grp.getgrgid(root_info.pw_gid).gr_name
|
||||
|
||||
@ -108,121 +108,108 @@ class TestEnsureUv:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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.mkdir()
|
||||
(venv_dir / "old_file").write_text("stale")
|
||||
|
||||
uv_bin = str(tmp_path / "bin" / "uv")
|
||||
call_log: list[list[str]] = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
m = MagicMock(returncode=0)
|
||||
if cmd[1] == "venv":
|
||||
# Simulate uv creating the venv dir
|
||||
venv_dir.mkdir(exist_ok=True)
|
||||
bin_dir = venv_dir / "bin"
|
||||
call_log.append(list(cmd))
|
||||
m = MagicMock(returncode=0, stderr="", stdout="")
|
||||
if len(cmd) >= 2 and cmd[1] == "venv":
|
||||
# Simulate uv creating the venv dir with a python interpreter
|
||||
bin_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin")
|
||||
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:
|
||||
m.stdout = "Python 3.11.0"
|
||||
return m
|
||||
|
||||
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
|
||||
patch("hermes_cli.managed_uv.shutil.rmtree") as mock_rmtree:
|
||||
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 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):
|
||||
venv_dir = tmp_path / "venv"
|
||||
uv_bin = str(tmp_path / "bin" / "uv")
|
||||
|
||||
with patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \
|
||||
patch("hermes_cli.managed_uv.shutil.rmtree"):
|
||||
with patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
|
||||
from hermes_cli.managed_uv import rebuild_venv
|
||||
result = rebuild_venv(uv_bin, venv_dir)
|
||||
assert result is False
|
||||
|
||||
def test_retries_with_clear_when_dir_already_exists(self, tmp_path):
|
||||
"""On Windows, rmtree can silently fail when an open handle holds a
|
||||
file in the venv (running hermes.exe, gateway, AV scanner). uv then
|
||||
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."""
|
||||
def test_rebuild_success_without_python_returns_false(self, tmp_path):
|
||||
"""uv can exit 0 yet leave no interpreter; that must not count as success
|
||||
(guard adapted from #38511)."""
|
||||
venv_dir = tmp_path / "venv"
|
||||
uv_bin = str(tmp_path / "bin" / "uv")
|
||||
call_log: list[list[str]] = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
call_log.append(list(cmd))
|
||||
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"):
|
||||
with patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
from hermes_cli.managed_uv import rebuild_venv
|
||||
result = rebuild_venv(uv_bin, venv_dir)
|
||||
|
||||
assert result is False
|
||||
venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"]
|
||||
assert len(venv_calls) == 1, "should not retry on non-dir-exists failures"
|
||||
assert "--clear" not in venv_calls[0]
|
||||
assert result is False
|
||||
# Returned before the `python --version` probe ran (only the uv venv call).
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -411,7 +411,7 @@ class TestUnionWithPortalFreeRecommendations:
|
||||
}
|
||||
|
||||
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"]
|
||||
pricing = {"anthropic/claude-opus-4.6": self._PAID}
|
||||
with patch(
|
||||
@ -420,8 +420,9 @@ class TestUnionWithPortalFreeRecommendations:
|
||||
):
|
||||
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
||||
|
||||
assert ids[0] == "qwen/qwen3.6-plus" # prepended
|
||||
assert "anthropic/claude-opus-4.6" in ids
|
||||
# Curated ("HA") models stay first; Portal-only picks follow.
|
||||
assert ids[0] == "anthropic/claude-opus-4.6"
|
||||
assert ids[-1] == "qwen/qwen3.6-plus" # appended
|
||||
# Synthetic free pricing entry created
|
||||
assert p["qwen/qwen3.6-plus"] == self._FREE
|
||||
# Existing pricing untouched
|
||||
@ -509,7 +510,7 @@ class TestUnionWithPortalFreeRecommendations:
|
||||
},
|
||||
):
|
||||
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
|
||||
|
||||
|
||||
@ -535,7 +536,7 @@ class TestUnionWithPortalPaidRecommendations:
|
||||
}
|
||||
|
||||
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"]
|
||||
pricing = {"anthropic/claude-opus-4.6": self._PAID}
|
||||
with patch(
|
||||
@ -544,8 +545,9 @@ class TestUnionWithPortalPaidRecommendations:
|
||||
):
|
||||
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
|
||||
|
||||
assert ids[0] == "openai/gpt-5.4" # prepended
|
||||
assert "anthropic/claude-opus-4.6" in ids
|
||||
# Curated ("HA") models stay first; Portal-only picks follow.
|
||||
assert ids[0] == "anthropic/claude-opus-4.6"
|
||||
assert ids[-1] == "openai/gpt-5.4" # appended
|
||||
# Existing pricing untouched
|
||||
assert p["anthropic/claude-opus-4.6"] == self._PAID
|
||||
|
||||
@ -634,12 +636,12 @@ class TestUnionWithPortalPaidRecommendations:
|
||||
},
|
||||
):
|
||||
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.
|
||||
assert "openai/gpt-5.4" not in p
|
||||
|
||||
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"]
|
||||
pricing = {"anthropic/claude-opus-4.6": self._PAID}
|
||||
with patch(
|
||||
@ -648,9 +650,9 @@ class TestUnionWithPortalPaidRecommendations:
|
||||
):
|
||||
ids, _ = union_with_portal_paid_recommendations(curated, pricing, "")
|
||||
assert ids == [
|
||||
"anthropic/claude-opus-4.6",
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.5",
|
||||
"anthropic/claude-opus-4.6",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ from hermes_cli.tools_config import (
|
||||
_toolset_needs_configuration_prompt,
|
||||
CONFIGURABLE_TOOLSETS,
|
||||
TOOL_CATEGORIES,
|
||||
gui_toolset_label,
|
||||
_visible_providers,
|
||||
tools_command,
|
||||
)
|
||||
@ -79,6 +80,13 @@ def test_get_platform_tools_uses_default_when_platform_not_configured():
|
||||
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():
|
||||
assert any(ts_key == "messaging" for ts_key, _, _ in CONFIGURABLE_TOOLSETS)
|
||||
|
||||
|
||||
@ -423,6 +423,41 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path):
|
||||
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):
|
||||
"""Termux update path should target .[termux-all] when requested."""
|
||||
calls = []
|
||||
|
||||
@ -815,6 +815,24 @@ class TestWebServerEndpoints:
|
||||
for key, info in data.items():
|
||||
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):
|
||||
"""POST /api/env/reveal should return the real unredacted value."""
|
||||
from hermes_cli.config import save_env_value
|
||||
@ -974,6 +992,157 @@ class TestWebServerEndpoints:
|
||||
assert data["state"] == "not_configured"
|
||||
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):
|
||||
"""GET /api/auth/session-token should no longer exist (token injected via HTML)."""
|
||||
resp = self.client.get("/api/auth/session-token")
|
||||
@ -1967,7 +2136,7 @@ class TestNewEndpoints:
|
||||
assert resp.json() == [
|
||||
{
|
||||
"name": "web",
|
||||
"label": "🔍 Web Search & Scraping",
|
||||
"label": "Web Search & Scraping",
|
||||
"description": "web_search, web_extract",
|
||||
"enabled": True,
|
||||
"available": True,
|
||||
@ -1976,7 +2145,7 @@ class TestNewEndpoints:
|
||||
},
|
||||
{
|
||||
"name": "skills",
|
||||
"label": "📚 Skills",
|
||||
"label": "Skills",
|
||||
"description": "list, view, manage",
|
||||
"enabled": True,
|
||||
"available": True,
|
||||
@ -1985,7 +2154,7 @@ class TestNewEndpoints:
|
||||
},
|
||||
{
|
||||
"name": "memory",
|
||||
"label": "💾 Memory",
|
||||
"label": "Memory",
|
||||
"description": "persistent memory across sessions",
|
||||
"enabled": False,
|
||||
"available": False,
|
||||
@ -4015,4 +4184,3 @@ class TestValidateProviderCredential:
|
||||
def test_empty_value_rejected(self):
|
||||
data = self._post("OPENAI_API_KEY", " ").json()
|
||||
assert data["ok"] is False
|
||||
|
||||
|
||||
90
tests/hermes_cli/test_web_server_session_search.py
Normal file
90
tests/hermes_cli/test_web_server_session_search.py
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@ -231,3 +231,53 @@ class TestOpenVikingBrowse:
|
||||
"/api/v1/fs/ls",
|
||||
{"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}"
|
||||
)
|
||||
|
||||
@ -170,6 +170,135 @@ class TestGuessCategory:
|
||||
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:
|
||||
def test_track_then_quick_deletes_test(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
|
||||
@ -2716,6 +2716,44 @@ class TestListSessionsRich:
|
||||
ids = [s["id"] for s in sessions]
|
||||
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):
|
||||
"""Sub-agent children (parent NOT ended with 'branched') remain hidden."""
|
||||
db.create_session("root", "cli")
|
||||
@ -3786,3 +3824,57 @@ class TestSessionArchive:
|
||||
both = {s["id"] for s in db.list_sessions_rich(include_archived=True)}
|
||||
assert both == {"live", "hidden"}
|
||||
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
|
||||
|
||||
@ -372,7 +372,8 @@ class TestVisionDispatchLoopSafety:
|
||||
side_effect=lambda url, dest, **kw: _write_fake_image(dest),
|
||||
),
|
||||
patch(
|
||||
"tools.vision_tools._validate_image_url",
|
||||
"tools.vision_tools._validate_image_url_async",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
@ -416,7 +417,8 @@ class TestVisionDispatchLoopSafety:
|
||||
side_effect=lambda url, dest, **kw: _write_fake_image(dest),
|
||||
),
|
||||
patch(
|
||||
"tools.vision_tools._validate_image_url",
|
||||
"tools.vision_tools._validate_image_url_async",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
|
||||
@ -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():
|
||||
optional_dependencies = _load_optional_dependencies()
|
||||
|
||||
|
||||
@ -5474,6 +5474,8 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
assert requeued["session_id"] == "proc_busy_test"
|
||||
finally:
|
||||
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):
|
||||
@ -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["system_prompt"] == "You are Hermes."
|
||||
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
|
||||
)
|
||||
|
||||
@ -44,6 +44,7 @@ def _make_dummy_env(**kwargs):
|
||||
auto_mount_cwd=kwargs.get("auto_mount_cwd", False),
|
||||
env=kwargs.get("env"),
|
||||
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"
|
||||
|
||||
|
||||
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):
|
||||
"""Opt-out path: ``persist_across_processes=False`` skips the ps probe
|
||||
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], (
|
||||
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", "")
|
||||
|
||||
@ -595,6 +595,7 @@ class TestSendToPlatformChunking:
|
||||
"***",
|
||||
"C123",
|
||||
"*hello* from <https://example.com|Hermes>",
|
||||
thread_ts=None,
|
||||
)
|
||||
|
||||
def test_slack_bold_italic_formatted_before_send(self, monkeypatch):
|
||||
|
||||
@ -90,7 +90,12 @@ class TestSSHBulkUpload:
|
||||
assert "/home/testuser/.hermes/credentials" in mkdir_str
|
||||
|
||||
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.write_text("content a")
|
||||
|
||||
@ -105,11 +110,14 @@ class TestSSHBulkUpload:
|
||||
# Capture the staging dir from -C argument
|
||||
c_idx = cmd.index("-C")
|
||||
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")
|
||||
staging_paths.append(expected)
|
||||
assert os.path.islink(expected), f"Expected symlink at {expected}"
|
||||
assert os.readlink(expected) == os.path.abspath(str(f1))
|
||||
# File must exist (either as symlink or copy)
|
||||
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.stdout = MagicMock()
|
||||
|
||||
@ -156,14 +156,15 @@ def test_cli_and_gateway_env_maps_agree():
|
||||
|
||||
def test_save_config_set_supports_critical_bridged_keys():
|
||||
"""``hermes config set terminal.X true`` must propagate to .env for
|
||||
known-critical keys. This used to be an all-keys invariant but several
|
||||
pre-existing terminal keys (ssh_*, docker_forward_env, docker_volumes)
|
||||
aren't in _config_to_env_sync and are instead handled via the separate
|
||||
api_keys TERMINAL_SSH_* fallback path or user-edits-yaml-directly.
|
||||
known-critical keys. This used to be an all-keys invariant but the SSH
|
||||
terminal keys (ssh_*) aren't in _config_to_env_sync and are instead
|
||||
handled via the separate api_keys TERMINAL_SSH_* fallback path or
|
||||
user-edits-yaml-directly.
|
||||
|
||||
Until those gaps are audited and fixed, pin the specific keys that are
|
||||
load-bearing for the docker backend's ownership flag so the bug we just
|
||||
fixed cannot silently regress.
|
||||
load-bearing for the docker backend so the bugs we fixed cannot silently
|
||||
regress. (docker_volumes / docker_forward_env, previously listed here as
|
||||
gaps, are now bridged — see the dedicated tests below.)
|
||||
"""
|
||||
save_keys = _save_config_env_sync_keys()
|
||||
required = {
|
||||
@ -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 _save_config_env_sync_keys()
|
||||
assert "TERMINAL_DOCKER_ORPHAN_REAPER" in _terminal_tool_env_var_names()
|
||||
|
||||
|
||||
def test_docker_volumes_is_bridged_everywhere():
|
||||
"""Regression pin for ``terminal.docker_volumes`` being silently dropped by
|
||||
``hermes config set``.
|
||||
|
||||
The JSON list of ``host:container`` bind mounts was bridged by cli.py and
|
||||
gateway/run.py and consumed by terminal_tool (via json.loads), but was
|
||||
missing from set_config_value's _config_to_env_sync. So
|
||||
``hermes config set terminal.docker_volumes '["/host:/workspace"]'`` wrote
|
||||
config.yaml yet left the running process's TERMINAL_DOCKER_VOLUMES stale —
|
||||
the mounts didn't apply until a full restart. Same four-site bridge
|
||||
invariant as docker_env / docker_run_as_host_user.
|
||||
"""
|
||||
assert "docker_volumes" in _cli_env_map_keys()
|
||||
assert "docker_volumes" in _gateway_env_map_keys()
|
||||
assert "docker_volumes" in _save_config_env_sync_keys()
|
||||
assert "TERMINAL_DOCKER_VOLUMES" in _terminal_tool_env_var_names()
|
||||
|
||||
|
||||
def test_docker_forward_env_is_bridged_everywhere():
|
||||
"""Regression pin for ``terminal.docker_forward_env`` — the sibling gap to
|
||||
docker_volumes.
|
||||
|
||||
The JSON list of host env-var names forwarded into the container was
|
||||
bridged by cli.py and gateway/run.py and consumed by terminal_tool (via
|
||||
json.loads), but missing from set_config_value's _config_to_env_sync, so
|
||||
``hermes config set terminal.docker_forward_env '["GITHUB_TOKEN"]'`` had no
|
||||
effect on the running process until restart.
|
||||
"""
|
||||
assert "docker_forward_env" in _cli_env_map_keys()
|
||||
assert "docker_forward_env" in _gateway_env_map_keys()
|
||||
assert "docker_forward_env" in _save_config_env_sync_keys()
|
||||
assert "TERMINAL_DOCKER_FORWARD_ENV" in _terminal_tool_env_var_names()
|
||||
|
||||
@ -5,6 +5,7 @@ from unittest.mock import patch
|
||||
|
||||
from tools.url_safety import (
|
||||
is_safe_url,
|
||||
async_is_safe_url,
|
||||
is_always_blocked_url,
|
||||
_is_blocked_ip,
|
||||
_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
|
||||
|
||||
|
||||
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:
|
||||
"""Direct tests for the _is_blocked_ip helper."""
|
||||
|
||||
|
||||
@ -297,7 +297,7 @@ class TestErrorLoggingExcInfo:
|
||||
async def test_analysis_error_logs_exc_info(self, caplog):
|
||||
"""When vision_analyze_tool encounters an error, it should log with exc_info."""
|
||||
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",
|
||||
new_callable=AsyncMock,
|
||||
@ -329,7 +329,7 @@ class TestErrorLoggingExcInfo:
|
||||
return dest
|
||||
|
||||
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._image_to_base64_data_url",
|
||||
@ -451,7 +451,7 @@ class TestVisionSafetyGuards:
|
||||
|
||||
with (
|
||||
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,
|
||||
):
|
||||
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.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("USERPROFILE", str(fake_home))
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_choice = MagicMock()
|
||||
@ -580,6 +582,7 @@ class TestTildeExpansion:
|
||||
fake_home = tmp_path / "fakehome"
|
||||
fake_home.mkdir()
|
||||
monkeypatch.setenv("HOME", str(fake_home))
|
||||
monkeypatch.setenv("USERPROFILE", str(fake_home))
|
||||
|
||||
result = await vision_analyze_tool(
|
||||
"~/nonexistent.png", "describe this", "test/model"
|
||||
|
||||
@ -259,7 +259,10 @@ class TestBraveFreeSearchOnlyErrors:
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "brave-free"})
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123")
|
||||
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)
|
||||
|
||||
result_str = asyncio.get_event_loop().run_until_complete(
|
||||
|
||||
@ -229,7 +229,10 @@ class TestDDGSSearchOnlyErrors:
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "ddgs"})
|
||||
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: True)
|
||||
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)
|
||||
|
||||
result_str = asyncio.get_event_loop().run_until_complete(
|
||||
|
||||
@ -318,7 +318,10 @@ class TestSearXNGOnlyExtractCrawlErrors:
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "searxng"})
|
||||
monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080")
|
||||
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)
|
||||
|
||||
result_str = asyncio.get_event_loop().run_until_complete(
|
||||
|
||||
@ -372,7 +372,10 @@ class TestWebToolPolicy:
|
||||
from plugins.web.firecrawl import provider as firecrawl_provider
|
||||
|
||||
# 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
|
||||
# extract() during the web-provider migration. Patch it at the new
|
||||
# location.
|
||||
@ -406,7 +409,10 @@ class TestWebToolPolicy:
|
||||
from plugins.web.firecrawl import provider as firecrawl_provider
|
||||
|
||||
# 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):
|
||||
if url == "https://allowed.test":
|
||||
|
||||
@ -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):
|
||||
captured = {}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user