Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/desktop-multiterminal

This commit is contained in:
Brooklyn Nicholson 2026-06-28 21:37:52 -05:00
commit ae465e9fb8
50 changed files with 1836 additions and 559 deletions

View file

@ -2319,7 +2319,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_fire_first_delta()
agent._fire_reasoning_delta(thinking_text)
# Return the native Anthropic Message for downstream processing
# Return the native Anthropic Message for downstream processing.
# If the stream was interrupted (the event loop broke out above on
# agent._interrupt_requested), do NOT call get_final_message() — on
# a partially-consumed stream the SDK may hang draining remaining
# events or return a Message with incomplete tool_use blocks (partial
# JSON in `input`). The outer poll loop raises InterruptedError, so
# this return value is discarded anyway.
if agent._interrupt_requested:
return None
return stream.get_final_message()
def _call():

View file

@ -222,6 +222,28 @@ _DB_CONNSTR_RE = re.compile(
re.IGNORECASE,
)
# Bare-token credential in a web/transport URL: ``scheme://TOKEN@host``.
# This is the ``git remote set-url origin https://PASSWORD@github.com/...``
# shape from issue #6396 — a single opaque credential in the userinfo position
# with NO ``user:pass`` colon. It is unambiguously a secret: legitimate
# round-trip URLs (OAuth callbacks, magic links, pre-signed shares — see the
# "Web-URL redaction is intentionally OFF" note in redact_sensitive_text) carry
# their tokens in the QUERY STRING, never in bare userinfo. The colon form
# ``user:pass@`` is deliberately left to pass through (commit "pass web URLs
# through unchanged", #34029) and is NOT matched here — the token class forbids
# ``:``. DB schemes are handled by _DB_CONNSTR_RE above and excluded here.
#
# Guards against false positives:
# - 8+ char floor skips short usernames (git, admin, root, deploy, ubuntu).
# - The token class ``[^\s:@/]`` cannot cross ``/``, so an ``@`` sitting in a
# path or query (e.g. ``?q=user@example.com``) is never treated as userinfo.
_URL_BARE_TOKEN_RE = re.compile(
r"((?:https?|wss?|git|ssh|ftp|ftps|sftp)://)" # scheme
r"([^\s:@/]{8,})" # bare token (no colon/slash/@), 8+ chars
r"(@[^\s]+)", # @host...
re.IGNORECASE,
)
# JWT tokens: header.payload[.signature] — always start with "eyJ" (base64 for "{")
# Matches 1-part (header only), 2-part (header.payload), and full 3-part JWTs.
_JWT_RE = re.compile(
@ -564,6 +586,16 @@ def redact_sensitive_text(
else:
text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text)
# Bare-token userinfo in web/transport URLs: ``scheme://TOKEN@host``.
# The git-remote-with-embedded-password shape from #6396. Only the
# colon-less bare-token form is redacted — ``user:pass@`` and
# query-string tokens are left to pass through (see the web-URL note
# below). See _URL_BARE_TOKEN_RE for the false-positive guards.
text = _URL_BARE_TOKEN_RE.sub(
lambda m: f"{m.group(1)}{_mask_token(m.group(2))}{m.group(3)}",
text,
)
# JWT tokens (eyJ... — base64-encoded JSON headers)
if "eyJ" in text:
text = _JWT_RE.sub(lambda m: _mask_token(m.group(0)), text)
@ -575,7 +607,12 @@ def redact_sensitive_text(
# blanket-redacting param values by name breaks those skills mid-flow.
# Known credential shapes (sk-, ghp_, JWTs, etc.) inside URLs are still
# caught by _PREFIX_RE and _JWT_RE above. DB connection-string passwords
# are still caught by _DB_CONNSTR_RE.
# are still caught by _DB_CONNSTR_RE. The ONE userinfo case still redacted
# is the colon-less bare-token form ``scheme://TOKEN@host`` (#6396, handled
# by _URL_BARE_TOKEN_RE in the ``://`` block above): a bare credential in
# userinfo is never a round-trip workflow token (those live in the query
# string), so masking it can't break a skill. The ``user:pass@`` form is
# left to pass through per #34029.
# Form-urlencoded bodies (only triggers on clean k=v&k=v inputs).
if "&" in text and "=" in text:

View file

@ -19,7 +19,7 @@ import {
type SubagentStreamEntry
} from '@/store/subagents'
import { OverlayView } from '../overlays/overlay-view'
import { Panel, PanelEmpty, PanelHeader } from '../overlays/panel'
// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the
// same visual vocabulary as the chat tool blocks.
@ -86,18 +86,16 @@ export function AgentsView({ onClose }: AgentsViewProps) {
const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession])
return (
<OverlayView
closeLabel={t.agents.close}
contentClassName="px-5 pt-5 pb-4 sm:px-6"
onClose={onClose}
rootClassName="mx-auto max-w-3xl"
>
<header className="mb-3 shrink-0">
<h2 className="text-sm font-semibold text-foreground">{t.agents.title}</h2>
<p className="text-xs text-muted-foreground/80">{t.agents.subtitle}</p>
</header>
<SubagentTree tree={tree} />
</OverlayView>
<Panel closeLabel={t.agents.close} onClose={onClose}>
{tree.length === 0 ? (
<PanelEmpty description={t.agents.emptyDesc} icon="hubot" title={t.agents.emptyTitle} />
) : (
<>
<PanelHeader subtitle={t.agents.subtitle} title={t.agents.title} />
<SubagentTree tree={tree} />
</>
)}
</Panel>
)
}

View file

@ -9,7 +9,16 @@ import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway,
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { Activity, AlertCircle, BarChart3, Bookmark, BookmarkFilled, Download, Pin, Trash2 } from '@/lib/icons'
import {
Activity,
AlertCircle,
BarChart3,
Bookmark,
BookmarkFilled,
Download,
MessageCircle,
Trash2
} from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
@ -263,7 +272,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
{SECTIONS.map(value => (
<OverlayNavItem
active={section === value}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : BarChart3}
icon={value === 'sessions' ? MessageCircle : value === 'system' ? Activity : BarChart3}
key={value}
label={cc.sections[value]}
onClick={() => setSection(value)}
@ -361,7 +370,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
/>
) : (
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-4">
<div className="border-b border-(--ui-stroke-tertiary) pb-4">
<div>
{status ? (
<div className="grid gap-2">
<div className="flex items-start justify-between gap-3">
@ -406,7 +415,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
)}
</div>
<div className="flex min-h-0 flex-col">
<div className="flex min-h-0 flex-col pt-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.625rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
{cc.recentLogs}
@ -503,7 +512,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
</span>
)}
<div className="grid grid-cols-2 gap-x-4 gap-y-4 border-b border-(--ui-stroke-tertiary) pb-5 sm:grid-cols-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-4 py-2 sm:grid-cols-3">
<UsageStat label={cc.statSessions} value={formatInteger(totals.total_sessions)} />
<UsageStat label={cc.statApiCalls} value={formatInteger(totals.total_api_calls)} />
<UsageStat
@ -563,7 +572,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
)}
</section>
<div className="grid min-h-0 gap-x-8 gap-y-5 border-t border-(--ui-stroke-tertiary) pt-5 sm:grid-cols-2">
<div className="grid min-h-0 gap-x-8 gap-y-5 pt-1 sm:grid-cols-2">
<UsageList
emptyLabel={cc.noModelUsage}
rows={byModel.slice(0, 6).map(entry => ({

View file

@ -14,7 +14,6 @@ import {
DialogTitle
} from '@/components/ui/dialog'
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 {
@ -30,14 +29,28 @@ import {
updateCronJob
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, Clock } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { AlertTriangle } from '@/lib/icons'
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import {
Panel,
PanelAction,
PanelAddButton,
PanelBlock,
PanelBody,
PanelDetail,
PanelEmpty,
PanelHeader,
PanelList,
PanelListRow,
PanelMeta,
PanelPill,
type PanelPillTone,
PanelRowMenu,
PanelSectionLabel
} from '../overlays/panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { jobState, jobTitle, STATE_DOT } from './job-state'
@ -56,7 +69,7 @@ const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
{ value: 'custom' }
]
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
const STATE_TONE: Record<string, PanelPillTone> = {
enabled: 'good',
scheduled: 'good',
running: 'good',
@ -66,13 +79,6 @@ const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
completed: 'muted'
}
const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = {
good: 'bg-primary/10 text-primary',
muted: 'bg-muted text-muted-foreground',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
bad: 'bg-destructive/10 text-destructive'
}
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}` : value)
@ -321,7 +327,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
pendingScrollRef.current = null
requestAnimationFrame(() => {
document.querySelector(`[data-cron-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' })
document.querySelector(`[data-panel-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' })
})
}, [selectedJob])
@ -406,60 +412,66 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
}
return (
<OverlayView closeLabel={c.close} onClose={onClose}>
<Panel closeLabel={c.close} onClose={onClose}>
{loading && jobs.length === 0 ? (
<PageLoader label={c.loading} />
) : totalCount === 0 ? (
<PanelEmpty
action={
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
{c.newCron}
</Button>
}
description={c.emptyDescNew}
icon="watch"
title={c.emptyTitleNew}
/>
) : (
<OverlaySplitLayout>
<OverlaySidebar>
<OverlayNewButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
{totalCount > 0 && (
<SearchField
aria-label={c.search}
containerClassName="mb-1 w-full px-2"
onChange={setQuery}
placeholder={c.search}
value={query}
/>
)}
{visibleJobs.map(job => (
<CronJobListRow
active={selectedJob?.id === job.id}
c={c}
job={job}
key={job.id}
onSelect={() => setSelectedJobId(job.id)}
/>
))}
{visibleJobs.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
{totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
</p>
)}
</OverlaySidebar>
<>
<PanelHeader subtitle={c.count(totalCount)} title={c.title} />
<PanelBody>
<PanelList
onSearchChange={setQuery}
searchLabel={c.search}
searchPlaceholder={c.search}
searchValue={query}
>
{visibleJobs.map(job => (
<CronJobListRow
active={selectedJob?.id === job.id}
job={job}
key={job.id}
menu={
<PanelRowMenu
items={[
{ icon: 'edit', label: c.edit, onSelect: () => setEditor({ mode: 'edit', job }) },
{ icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' }
]}
/>
}
onSelect={() => setSelectedJobId(job.id)}
/>
))}
{visibleJobs.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{c.emptyTitleSearch}</p>
)}
<PanelAddButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
</PanelList>
<OverlayMain className="px-0">
{selectedJob ? (
<CronJobDetail
busy={busyJobId === selectedJob.id}
c={c}
job={selectedJob}
onDelete={() => setPendingDelete(selectedJob)}
onEdit={() => setEditor({ mode: 'edit', job: selectedJob })}
onOpenSession={onOpenSession}
onPauseResume={() => void handlePauseResume(selectedJob)}
onTrigger={() => void handleTrigger(selectedJob)}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Clock className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">{totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}</p>
</div>
</div>
<PanelEmpty description={c.emptyDescSearch} icon="search" />
)}
</OverlayMain>
</OverlaySplitLayout>
</PanelBody>
</>
)}
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
@ -488,42 +500,32 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
</DialogFooter>
</DialogContent>
</Dialog>
</OverlayView>
</Panel>
)
}
function CronJobListRow({
active,
c,
job,
menu,
onSelect
}: {
active: boolean
c: Translations['cron']
job: CronJob
menu?: React.ReactNode
onSelect: () => void
}) {
const state = jobState(job)
return (
<button
className={cn(
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
)}
data-cron-row={job.id}
onClick={onSelect}
type="button"
>
<span className="flex w-full items-center gap-2">
<span
aria-hidden="true"
className={cn('size-1.5 shrink-0 rounded-full', STATE_DOT[state] ?? 'bg-muted-foreground')}
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{jobTitle(job)}</span>
</span>
<span className="truncate pl-3.5 text-[0.66rem] text-muted-foreground">{jobScheduleDisplay(job)}</span>
</button>
<PanelListRow
active={active}
dotClassName={STATE_DOT[state] ?? 'bg-muted-foreground'}
menu={menu}
onSelect={onSelect}
rowKey={job.id}
title={jobTitle(job)}
/>
)
}
@ -531,8 +533,6 @@ function CronJobDetail({
busy,
c,
job,
onDelete,
onEdit,
onOpenSession,
onPauseResume,
onTrigger
@ -540,8 +540,6 @@ function CronJobDetail({
busy: boolean
c: Translations['cron']
job: CronJob
onDelete: () => void
onEdit: () => void
onOpenSession?: (sessionId: string) => void
onPauseResume: () => void
onTrigger: () => void
@ -552,69 +550,49 @@ function CronJobDetail({
const prompt = jobPrompt(job)
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<header className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-xl font-semibold tracking-tight">{jobTitle(job)}</h3>
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
{deliver && deliver !== DEFAULT_DELIVER && (
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
)}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.7rem] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock className="size-3" />
{jobScheduleDisplay(job)}
</span>
<span>
{c.last} {formatTime(job.last_run_at)}
</span>
<span>
{c.next} {formatTime(job.next_run_at)}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button disabled={busy} onClick={onPauseResume} size="sm" variant="outline">
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
{isPaused ? c.resumeTitle : c.pauseTitle}
</Button>
<Button disabled={busy} onClick={onTrigger} size="sm" variant="outline">
<Codicon name="zap" size="0.875rem" />
{c.triggerNow}
</Button>
<Button onClick={onEdit} size="sm" variant="outline">
<Codicon name="edit" size="0.875rem" />
{c.edit}
</Button>
<Button
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
size="sm"
variant="ghost"
>
<Codicon name="trash" size="0.875rem" />
</Button>
</div>
</div>
{prompt && <p className="line-clamp-3 text-xs text-muted-foreground">{prompt}</p>}
{job.last_error && (
<p className="inline-flex items-start gap-1 text-[0.7rem] text-destructive">
<AlertTriangle className="mt-px size-3 shrink-0" />
<span className="line-clamp-2">{job.last_error}</span>
</p>
)}
</header>
<CronJobRuns c={c} jobId={job.id} onOpenSession={onOpenSession} />
<PanelDetail>
<header className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{jobTitle(job)}</h3>
<PanelPill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</PanelPill>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<PanelAction disabled={busy} icon={isPaused ? 'play' : 'debug-pause'} onClick={onPauseResume}>
{isPaused ? c.resumeTitle : c.pauseTitle}
</PanelAction>
<PanelAction disabled={busy} icon="zap" onClick={onTrigger}>
{c.triggerNow}
</PanelAction>
</div>
</div>
</div>
</div>
<PanelMeta
rows={[
{ label: c.frequencyLabel, value: jobScheduleDisplay(job) },
{ label: c.last.replace(/:$/, ''), value: formatTime(job.last_run_at) },
{ label: c.next.replace(/:$/, ''), value: formatTime(job.next_run_at) },
{ label: c.deliverLabel, value: c.deliveryLabels[deliver] ?? deliver }
]}
/>
{job.last_error ? (
<div className="flex items-start gap-1.5 rounded bg-destructive/10 p-2 text-[0.7rem] text-destructive">
<AlertTriangle className="mt-px size-3 shrink-0" />
<span className="min-w-0 break-words">{job.last_error}</span>
</div>
) : null}
</header>
{prompt ? (
<section className="space-y-1.5">
<PanelSectionLabel>{c.promptLabel}</PanelSectionLabel>
<PanelBlock>{prompt}</PanelBlock>
</section>
) : null}
<CronJobRuns c={c} jobId={job.id} onOpenSession={onOpenSession} />
</PanelDetail>
)
}
@ -685,10 +663,10 @@ function CronJobRuns({
return (
<div>
<div className="mb-1.5 text-[0.62rem] font-medium uppercase tracking-wide text-muted-foreground">
<PanelSectionLabel className="mb-1.5">
{c.runHistory}
{runs && runs.length > 0 ? ` · ${runs.length}` : ''}
</div>
</PanelSectionLabel>
{runs === null ? (
<div className="flex items-center gap-1.5 py-1 text-xs text-muted-foreground">
<Codicon name="loading" size="0.75rem" spinning />
@ -699,13 +677,13 @@ function CronJobRuns({
<div className="flex flex-col gap-px">
{runs.map(run => (
<button
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs hover:bg-(--chrome-action-hover) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs transition-colors duration-100 hover:bg-(--ui-row-hover-background) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
key={run.id}
onClick={() => onOpenSession?.(run.id)}
type="button"
>
<span className="truncate text-foreground">{run.title?.trim() || run.preview?.trim() || run.id}</span>
<span className="shrink-0 text-[0.62rem] text-muted-foreground tabular-nums">
<span className="truncate text-foreground/85">{run.title?.trim() || run.preview?.trim() || run.id}</span>
<span className="shrink-0 text-[0.62rem] text-muted-foreground/55 tabular-nums">
{formatRunTime(run.last_active || run.started_at)}
</span>
</button>
@ -716,16 +694,6 @@ function CronJobRuns({
)
}
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
return (
<span
className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}
>
{children}
</span>
)
}
function CronEditorDialog({
editor,
onClose,

View file

@ -598,7 +598,7 @@ export function DesktopController() {
}
}, [])
const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway)
const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway)
const updateActiveSessionRuntimeInfo = useCallback(
(info: { branch?: string; cwd?: string }) => {
@ -1078,7 +1078,6 @@ export function DesktopController() {
commandCenterOpen,
extraLeftItems: statusbarItemGroups.flat.left,
extraRightItems: statusbarItemGroups.flat.right,
gatewayLogLines,
gatewayState,
inferenceStatus,
openAgents,

View file

@ -1,26 +1,11 @@
import type { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { cn } from '@/lib/utils'
export const overlayCardClass =
'rounded-lg border border-[color-mix(in_srgb,var(--dt-border)_52%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_34%,transparent)]'
interface OverlayCardProps extends ComponentProps<'div'> {
children: ReactNode
}
interface OverlayActionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
tone?: 'default' | 'danger' | 'subtle'
}
export function OverlayCard({ children, className, ...props }: OverlayCardProps) {
return (
<div className={cn(overlayCardClass, className)} {...props}>
{children}
</div>
)
}
export function OverlayActionButton({
children,
className,

View file

@ -1,33 +0,0 @@
import type { RefObject } from 'react'
import { SearchField } from '@/components/ui/search-field'
interface OverlaySearchInputProps {
containerClassName?: string
inputRef?: RefObject<HTMLInputElement | null>
loading?: boolean
onChange: (value: string) => void
placeholder: string
value: string
}
// Borderless underline search — matches the tools/skills page (PageSearchShell).
export function OverlaySearchInput({
containerClassName,
inputRef,
loading = false,
onChange,
placeholder,
value
}: OverlaySearchInputProps) {
return (
<SearchField
containerClassName={containerClassName}
inputRef={inputRef}
loading={loading}
onChange={onChange}
placeholder={placeholder}
value={value}
/>
)
}

View file

@ -1,7 +1,5 @@
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
@ -50,9 +48,10 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
return (
<aside
className={cn(
// pt clears the floating titlebar/header; the bg itself fills from the
// card's top edge so there's no surface-colored gap above the sidebar.
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
// pt clears the in-card close button (the OverlayView now insets the
// whole card below the OS titlebar); the bg fills from the card's top
// edge so there's no surface-colored gap above the sidebar.
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3 pt-[calc(var(--titlebar-height)/2+1rem)]',
className
)}
>
@ -65,7 +64,7 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
return (
<main
className={cn(
'flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
'flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)/2+1rem)]',
PAGE_INSET_X,
className
)}
@ -75,31 +74,6 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
)
}
// Boxless "+ New …" action that tops an OverlaySidebar list (profiles, cron, …).
// The text variant underlines on hover, which also strokes the icon glyph — so
// we keep the button itself underline-free and underline only the label span.
export function OverlayNewButton({
icon = 'add',
label,
onClick
}: {
icon?: string
label: string
onClick: () => void
}) {
return (
<Button
className="group mb-1 w-full justify-start gap-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={onClick}
size="sm"
variant="ghost"
>
<Codicon name={icon} />
<span className="underline-offset-4 group-hover:underline">{label}</span>
</Button>
)
}
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
return (
<button

View file

@ -49,7 +49,15 @@ export function OverlayView({
return (
<div
className="fixed inset-0 z-50 bg-black/22 p-3 backdrop-blur-[0.125rem] sm:p-6"
className={cn(
'fixed inset-0 z-50 bg-black/22 backdrop-blur-[0.125rem]',
// Equidistant inset on every side. The top value is driven by the
// titlebar height so the card clears the OS traffic-lights vertically;
// since the card top already sits below them, the left needs no extra
// inset — keeping all sides equal so the card is ~full-width at any size.
'p-[calc(var(--titlebar-height)+0.625rem)]',
'sm:p-[calc(var(--titlebar-height)+0.875rem)]'
)}
onClick={event => {
if (event.target === event.currentTarget) {
closeOverlay()

View file

@ -0,0 +1,377 @@
import type { ReactNode } 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 { SearchField } from '@/components/ui/search-field'
import { translateNow } from '@/i18n'
import { cn } from '@/lib/utils'
import { OverlayView } from './overlay-view'
// Overlay "panel" primitive — the centered, capped card + framed chrome lifted
// straight from the trace / agents overlay so every non-settings overlay (cron,
// profiles, …) speaks the same visual language: tight type scale, muted
// opacities, NO container borders (rows separate via the row-hover/active bg
// vars + gaps, exactly like the trace waterfall labels).
//
// Compose it as:
// <Panel onClose>
// <PanelHeader title subtitle actions={…} />
// <PanelBody> // master/detail row
// <PanelList>…</PanelList>
// <PanelDetail>…</PanelDetail>
// </PanelBody>
// </Panel>
//
// Single-column views drop their content straight after the header.
interface PanelProps {
children: ReactNode
// Root layout override (the card already fills the equidistant inset).
className?: string
closeLabel?: string
contentClassName?: string
onClose: () => void
}
export function Panel({
children,
className,
closeLabel = translateNow('common.close'),
contentClassName,
onClose
}: PanelProps) {
return (
<OverlayView
closeLabel={closeLabel}
// Top pad aligns the header title's center with the floating close button
// (which sits at 0.1875rem + titlebar/2, -translate-y-1/2). The X is
// absolute so it costs no layout space — the header rides up next to it.
contentClassName={cn(
'flex h-full min-h-0 flex-col px-4 pb-4 pt-[calc(var(--titlebar-height)/2-0.4375rem)] sm:px-5',
contentClassName
)}
onClose={onClose}
rootClassName={cn('flex h-full w-full flex-col', className)}
>
{children}
</OverlayView>
)
}
interface PanelHeaderProps {
// Right-aligned controls (search, "+ New", segmented control, …).
actions?: ReactNode
subtitle?: ReactNode
title: ReactNode
}
export function PanelHeader({ actions, subtitle, title }: PanelHeaderProps) {
return (
<header className="mb-3 flex shrink-0 items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
{subtitle ? <p className="truncate text-xs text-muted-foreground/80">{subtitle}</p> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-1.5">{actions}</div> : null}
</header>
)
}
export function PanelBody({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('flex min-h-0 flex-1 gap-5 overflow-hidden', className)}>{children}</div>
}
interface PanelListProps {
children: ReactNode
className?: string
// Pass an onSearchChange to bake a full-bleed filter field in above the items
// (pinned; the rows scroll under it). Controlled via searchValue.
onSearchChange?: (value: string) => void
searchLabel?: string
searchPlaceholder?: string
searchValue?: string
}
// Left master list. Dense + borderless, like the trace waterfall's label tree:
// single-line rows that touch, separated from the detail only by the body gap.
// An optional search field pins to the top, full-bleed, above the scroll.
export function PanelList({
children,
className,
onSearchChange,
searchLabel,
searchPlaceholder,
searchValue
}: PanelListProps) {
return (
<div className={cn('flex w-52 shrink-0 flex-col', className)}>
{onSearchChange ? (
<SearchField
aria-label={searchLabel ?? searchPlaceholder ?? ''}
containerClassName="mb-1 w-full shrink-0"
onChange={onSearchChange}
placeholder={searchPlaceholder ?? ''}
value={searchValue ?? ''}
/>
) : null}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain">{children}</div>
</div>
)
}
interface PanelListRowProps {
active: boolean
// Leading status dot color class (e.g. 'bg-emerald-500'); omit for none.
dotClassName?: string
// Leading codicon glyph name (used when there's no lead/dot).
icon?: string
// Custom leading element (colored swatch, avatar, …). Wins over dot/icon.
lead?: ReactNode
// Trailing per-row kebab menu (pass a <PanelRowMenu/>). Reveals on hover/focus.
menu?: ReactNode
// Short always-visible trailing meta (a tag/time, like the trace label's duration).
meta?: ReactNode
onSelect: () => void
rowKey?: string
title: ReactNode
}
// A row is a container (not a <button>) so it can host both the select target
// and a kebab menu without nesting interactive elements. Hover/active bg lives
// on the wrapper so the whole row highlights as one.
export function PanelListRow({
active,
dotClassName,
icon,
lead,
menu,
meta,
onSelect,
rowKey,
title
}: PanelListRowProps) {
return (
<div
className={cn(
'group/row relative flex h-7 w-full items-center rounded-md text-[0.78rem] transition-colors duration-100 ease-out',
active
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
)}
data-panel-row={rowKey}
>
<button
className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-md pl-2 pr-1 text-left"
onClick={onSelect}
type="button"
>
{lead ??
(dotClassName ? (
<span aria-hidden="true" className={cn('size-1.5 shrink-0 rounded-full', dotClassName)} />
) : icon ? (
<Codicon className="shrink-0 text-muted-foreground/55" name={icon} size="0.85rem" />
) : null)}
<span className="min-w-0 flex-1 truncate font-medium text-foreground/85">{title}</span>
</button>
{meta ? <span className="shrink-0 pr-2 text-[0.62rem] tabular-nums text-muted-foreground/45">{meta}</span> : null}
{menu ? <div className="shrink-0 pr-1">{menu}</div> : null}
</div>
)
}
export interface PanelMenuItem {
disabled?: boolean
icon?: string
label: string
onSelect: () => void
tone?: 'danger' | 'default'
}
// Per-row "⋮" actions menu — mirrors the sidebar session row's settled pattern
// (size-5 ghost trigger + kebab-vertical codicon + w-40 content). Hidden until
// the row is hovered/focused (or the menu is open). Returns null with no items
// (e.g. the default profile, which can't be renamed/deleted).
export function PanelRowMenu({ items, label = 'Actions' }: { items: PanelMenuItem[]; label?: string }) {
if (items.length === 0) {
return null
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={label}
className="size-5 rounded-[4px] bg-transparent text-(--ui-text-tertiary) opacity-0 transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 focus-visible:ring-0 group-hover/row:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100 [&_svg]:size-3.5!"
size="icon"
title={label}
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.875rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40" sideOffset={6}>
{items.map(item => (
<DropdownMenuItem
disabled={item.disabled}
key={item.label}
onSelect={item.onSelect}
variant={item.tone === 'danger' ? 'destructive' : undefined}
>
{item.icon ? <Codicon name={item.icon} size="0.875rem" /> : null}
<span>{item.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
// Scrolling detail region. Fills the column (no right rail here, unlike the
// trace inspector), so the content stretches the full available width.
export function PanelDetail({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain', className)}>
<div className="space-y-4 pb-6 pl-1 pr-2">{children}</div>
</div>
)
}
interface PanelEmptyProps {
action?: ReactNode
description?: ReactNode
// Codicon glyph name (e.g. 'hubot', 'warning', 'loading~spin').
icon?: string
title?: ReactNode
}
export function PanelEmpty({ action, description, icon = 'inbox', title }: PanelEmptyProps) {
return (
<div className="grid flex-1 place-items-center px-6 py-10 text-center">
<div className="flex flex-col items-center gap-2">
<Codicon className="text-muted-foreground/50" name={icon} size="1.25rem" />
{title ? <p className="text-sm font-medium text-foreground/90">{title}</p> : null}
{description ? (
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground/70">{description}</p>
) : null}
{action ? <div className="mt-2">{action}</div> : null}
</div>
</div>
)
}
export function PanelSectionLabel({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn('text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground/50', className)}>
{children}
</div>
)
}
// Inspector-style key/value grid (mirrors the trace span inspector's <dl>).
export interface PanelMetaRow {
label: ReactNode
value: ReactNode
}
export function PanelMeta({ className, rows }: { className?: string; rows: PanelMetaRow[] }) {
return (
<dl className={cn('grid grid-cols-[5rem_1fr] gap-x-2 gap-y-1 text-[0.7rem]', className)}>
{rows.map((row, i) => (
<div className="contents" key={typeof row.label === 'string' ? row.label : i}>
<dt className="truncate text-muted-foreground/55">{row.label}</dt>
<dd className="min-w-0 break-words text-foreground/85">{row.value}</dd>
</div>
))}
</dl>
)
}
// Monospace content block (job prompt, etc.) — mirrors the inspector's
// input/output <pre> blocks: subtle bg, no border.
export function PanelBlock({ children, className }: { children: ReactNode; className?: string }) {
return (
<pre
className={cn(
'max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-foreground/5 p-2.5 text-[0.68rem] leading-relaxed text-foreground/80',
className
)}
>
{children}
</pre>
)
}
export type PanelPillTone = 'bad' | 'good' | 'muted' | 'warn'
const PILL_TONE: Record<PanelPillTone, string> = {
bad: 'bg-destructive/10 text-destructive',
good: 'bg-primary/10 text-primary',
muted: 'bg-foreground/10 text-muted-foreground',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300'
}
export function PanelPill({ children, tone = 'muted' }: { children: ReactNode; tone?: PanelPillTone }) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.62rem] font-medium capitalize',
PILL_TONE[tone]
)}
>
{children}
</span>
)
}
// Self-describing centered "+" that sits as the LAST item in a PanelList. The
// label rides aria/title only — no visible text.
export function PanelAddButton({
icon = 'add',
label,
onClick
}: {
icon?: string
label: string
onClick: () => void
}) {
return (
<Button
aria-label={label}
className="h-7 w-full shrink-0 justify-center text-muted-foreground/70 hover:bg-(--ui-row-hover-background) hover:text-foreground"
onClick={onClick}
size="sm"
title={label}
variant="ghost"
>
<Codicon name={icon} size="0.875rem" />
</Button>
)
}
// Visible ghost action for a detail header (cron pause/resume/trigger, …).
export function PanelAction({
children,
disabled,
icon,
onClick
}: {
children: ReactNode
disabled?: boolean
icon: string
onClick: () => void
}) {
return (
<Button
className="gap-1.5 text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground"
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
>
<Codicon name={icon} size="0.875rem" />
{children}
</Button>
)
}

View file

@ -1,8 +1,10 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
@ -18,21 +20,34 @@ import {
createProfile,
deleteProfile,
getProfiles,
getProfileSetupCommand,
getProfileSoul,
type ProfileInfo,
renameProfile,
updateProfileSoul
} from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
import { AlertTriangle, Save } from '@/lib/icons'
import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { slug } from '@/lib/sanitize'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $profileColors } from '@/store/profile'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import {
Panel,
PanelAddButton,
PanelBody,
PanelDetail,
PanelEmpty,
PanelHeader,
PanelList,
PanelListRow,
PanelMeta,
PanelPill,
PanelRowMenu,
PanelSectionLabel
} from '../overlays/panel'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@ -49,7 +64,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
const p = t.profiles
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
const [selectedName, setSelectedName] = useState<null | string>(null)
const [query, setQuery] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [deleting, setDeleting] = useState(false)
@ -83,6 +100,18 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
return profiles.find(p => p.name === selectedName) ?? profiles[0] ?? null
}, [profiles, selectedName])
const visibleProfiles = useMemo(() => {
const q = query.trim().toLowerCase()
if (!profiles || !q) {
return profiles ?? []
}
return profiles.filter(
profile => profile.name.toLowerCase().includes(q) || (profile.model ?? '').toLowerCase().includes(q)
)
}, [profiles, query])
const handleCreate = useCallback(
async (name: string, cloneFrom: null | string) => {
const trimmed = name.trim()
@ -140,46 +169,79 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
}, [p, pendingDelete, refresh])
return (
<OverlayView closeLabel={p.close} onClose={onClose}>
<Panel closeLabel={p.close} onClose={onClose}>
{!profiles ? (
<PageLoader label={p.loading} />
) : profiles.length === 0 ? (
<PanelEmpty
action={
<Button onClick={() => setCreateOpen(true)} size="sm">
{p.newProfile}
</Button>
}
description={p.createDesc}
icon="organization"
title={p.noProfiles}
/>
) : (
<OverlaySplitLayout>
<OverlaySidebar>
<OverlayNewButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
{profiles.map(profile => (
<ProfileRow
active={selected?.name === profile.name}
key={profile.name}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
))}
{profiles.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{p.noProfiles}</p>
)}
</OverlaySidebar>
<>
<PanelHeader subtitle={p.count(profiles.length)} title={p.title} />
<PanelBody>
<PanelList
onSearchChange={setQuery}
searchLabel={p.search}
searchPlaceholder={p.search}
searchValue={query}
>
{visibleProfiles.map(profile => (
<ProfileRow
active={selected?.name === profile.name}
key={profile.name}
menu={
<PanelRowMenu
items={
profile.is_default
? []
: [
{ icon: 'edit', label: p.rename, onSelect: () => setPendingRename(profile) },
{
icon: 'trash',
label: t.common.delete,
onSelect: () => setPendingDelete(profile),
tone: 'danger'
}
]
}
/>
}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
))}
<PanelAddButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
</PanelList>
<OverlayMain className="px-0">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
<ProfileDetail key={selected.name} profile={selected} />
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Users className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">{p.selectPrompt}</p>
</div>
</div>
<PanelEmpty description={p.selectPrompt} icon="account" />
)}
</OverlayMain>
</OverlaySplitLayout>
</PanelBody>
</>
)}
<RenameProfileDialog
currentName={pendingRename?.name ?? ''}
onClose={() => setPendingRename(null)}
onRename={async newName => {
if (pendingRename) {
await handleRename(pendingRename.name, newName)
setPendingRename(null)
}
}}
open={pendingRename !== null}
/>
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
@ -213,150 +275,106 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
</DialogFooter>
</DialogContent>
</Dialog>
</OverlayView>
</Panel>
)
}
function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: () => void; profile: ProfileInfo }) {
const { t } = useI18n()
const p = t.profiles
return (
<button
className={cn(
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
)}
onClick={onSelect}
type="button"
>
<span className="flex w-full items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{profile.name}</span>
{profile.is_default && <span className="text-[0.6rem] text-primary">{p.default}</span>}
</span>
<span className="text-[0.66rem] text-muted-foreground">
{p.skills(profile.skill_count)}
{profile.has_env ? ` · ${p.env}` : ''}
</span>
</button>
)
}
function ProfileDetail({
onDelete,
onRename,
function ProfileRow({
active,
menu,
onSelect,
profile
}: {
onDelete: () => void
onRename: (newName: string) => Promise<void>
active: boolean
menu?: React.ReactNode
onSelect: () => void
profile: ProfileInfo
}) {
const { t } = useI18n()
const p = t.profiles
const [renameOpen, setRenameOpen] = useState(false)
const [copying, setCopying] = useState(false)
const handleCopySetup = useCallback(async () => {
setCopying(true)
try {
const { command } = await getProfileSetupCommand(profile.name)
await navigator.clipboard.writeText(command)
notify({ kind: 'success', title: p.setupCopied, message: command })
} catch (err) {
notifyError(err, p.failedCopy)
} finally {
setCopying(false)
}
}, [p, profile.name])
const colors = useStore($profileColors)
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<header className="space-y-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">
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
{profile.is_default && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[0.65rem] font-medium text-primary">
{p.defaultBadge}
</span>
)}
{profile.has_env && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground">
.env
</span>
)}
</div>
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground" title={profile.path}>
{profile.path}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{!profile.is_default && (
<Button onClick={() => setRenameOpen(true)} size="sm" variant="outline">
<Pencil />
{p.rename}
</Button>
)}
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="outline">
<Terminal />
{copying ? p.copying : p.copySetup}
</Button>
{!profile.is_default && (
<Button
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
size="sm"
variant="ghost"
>
<Trash2 />
{t.common.delete}
</Button>
)}
</div>
</div>
<dl className="grid gap-2 text-xs sm:grid-cols-2">
<DetailRow label={p.modelLabel}>
{profile.model ? (
<>
<span className="font-mono">{profile.model}</span>
{profile.provider && <span className="text-muted-foreground"> · {profile.provider}</span>}
</>
) : (
<span className="text-muted-foreground">{p.notSet}</span>
)}
</DetailRow>
<DetailRow label={p.skillsLabel}>{profile.skill_count}</DetailRow>
</dl>
</header>
<SoulEditor profileName={profile.name} />
</div>
</div>
<RenameProfileDialog
currentName={profile.name}
onClose={() => setRenameOpen(false)}
onRename={async newName => {
await onRename(newName)
setRenameOpen(false)
}}
open={renameOpen}
/>
</div>
<PanelListRow
active={active}
lead={
<ProfileGlyph
color={resolveProfileColor(profile.name, colors)}
isDefault={profile.is_default}
name={profile.name}
/>
}
menu={menu}
onSelect={onSelect}
rowKey={profile.name}
title={profile.name}
/>
)
}
function DetailRow({ children, label }: { children: React.ReactNode; label: string }) {
// Leading glyph for a profile row, mirroring the sidebar rail: the default
// profile gets the `home` icon; named profiles get a soft color-tinted square
// with their initial in the profile's color.
function ProfileGlyph({ color, isDefault, name }: { color: null | string; isDefault: boolean; name: string }) {
if (isDefault) {
return <Codicon className="shrink-0 text-muted-foreground/70" name="home" size="0.9rem" />
}
const hue = color ?? 'var(--ui-text-quaternary)'
const initial =
name
.replace(/[^a-z0-9]/gi, '')
.charAt(0)
.toUpperCase() || '?'
return (
<div className="flex flex-wrap items-baseline gap-2">
<dt className="text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">{label}</dt>
<dd className="text-sm text-foreground">{children}</dd>
</div>
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
>
{initial}
</span>
)
}
function ProfileDetail({ profile }: { profile: ProfileInfo }) {
const { t } = useI18n()
const p = t.profiles
return (
<PanelDetail>
<header className="space-y-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{profile.name}</h3>
{profile.is_default && <PanelPill tone="good">{p.defaultBadge}</PanelPill>}
{profile.has_env && <PanelPill tone="muted">.env</PanelPill>}
</div>
<p className="mt-1 truncate font-mono text-[0.66rem] text-muted-foreground/55" title={profile.path}>
{profile.path}
</p>
</div>
<PanelMeta
rows={[
{
label: p.modelLabel,
value: profile.model ? (
<span className="font-mono">
{profile.model}
{profile.provider ? <span className="text-muted-foreground/55"> · {profile.provider}</span> : null}
</span>
) : (
<span className="text-muted-foreground/55">{p.notSet}</span>
)
},
{ label: p.skillsLabel, value: profile.skill_count }
]}
/>
</header>
<SoulEditor profileName={profile.name} />
</PanelDetail>
)
}
@ -419,7 +437,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
<section className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">SOUL.md</h4>
<PanelSectionLabel className="text-[0.7rem] tracking-[0.14em]">SOUL.md</PanelSectionLabel>
<p className="text-xs text-muted-foreground">{p.soulDesc}</p>
</div>
{dirty && <span className="text-[0.65rem] text-muted-foreground">{p.unsavedChanges}</span>}
@ -429,7 +447,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
<PageLoader className="min-h-44" label={p.loadingSoul} />
) : (
<Textarea
className="min-h-72 font-mono text-xs leading-5"
className="min-h-48 font-mono text-xs leading-5"
onChange={event => setContent(event.target.value)}
placeholder={isEmpty ? p.emptySoul : undefined}
value={content}
@ -437,7 +455,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
)}
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<div className="flex items-start gap-2 rounded bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>

View file

@ -213,7 +213,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
</div>
</OverlaySidebar>
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)/2+1rem)]">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (

View file

@ -1,20 +1,68 @@
import { useEffect, useRef, useState } from 'react'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { LogView } from '@/components/ui/log-view'
import { Tip } from '@/components/ui/tooltip'
import { getLogs } from '@/hermes'
import { useI18n } from '@/i18n'
import { Activity, AlertCircle, LayoutDashboard } from '@/lib/icons'
import { LayoutDashboard, RefreshCw } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { cn } from '@/lib/utils'
import { runGatewayRestart } from '@/store/system-actions'
import type { StatusResponse } from '@/types/hermes'
interface GatewayMenuPanelProps {
gatewayState: string
inferenceStatus: RuntimeReadinessResult | null
logLines: readonly string[]
onClose: () => void
onOpenSystem: () => void
statusSnapshot: StatusResponse | null
}
const LOG_TAIL = 120
const LOG_VISIBLE = 40
const LOG_POLL_MS = 3_000
// Per-connection WebSocket churn (accept/close/heartbeat) drowns out anything
// useful — strip it so the tail reads as real gateway activity at a glance.
const LOG_NOISE_RE = /\bws (?:accepted|closed|response sent|ping|pong)\b/i
// Live tail while the popover is mounted (i.e. open): poll on a tight cadence
// and stop on unmount, instead of a global always-on status poll.
function useGatewayLogTail(): string[] {
const [lines, setLines] = useState<string[]>([])
useEffect(() => {
let cancelled = false
const load = () =>
getLogs({ file: 'gui', lines: LOG_TAIL })
.then(res => {
if (cancelled) {
return
}
setLines(
res.lines
.map(line => line.trim())
.filter(line => line && !LOG_NOISE_RE.test(line))
.slice(-LOG_VISIBLE)
)
})
.catch(() => {})
void load()
const timer = window.setInterval(load, LOG_POLL_MS)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [])
return lines
}
const PLATFORM_TONE: Record<string, StatusTone> = {
connected: 'good',
connecting: 'warn',
@ -35,12 +83,27 @@ const trimLogLine = (raw: string) => raw.trim().replace(TIMESTAMP_RE, '').replac
export function GatewayMenuPanel({
gatewayState,
inferenceStatus,
logLines,
onClose,
onOpenSystem,
statusSnapshot
}: GatewayMenuPanelProps) {
const { t } = useI18n()
const copy = t.shell.gatewayMenu
// Both jumps open the system panel, which owns the full view — so dismiss the
// little status popover on the way out.
const openSystem = () => {
onClose()
onOpenSystem()
}
// Shared restart helper: never rejects and surfaces progress in the statusbar
// gateway indicator, so just fire and close.
const restart = () => {
onClose()
void runGatewayRestart()
}
const gatewayOpen = gatewayState === 'open'
const gatewayConnecting = gatewayState === 'connecting'
const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
@ -60,30 +123,50 @@ export function GatewayMenuPanel({
: copy.disconnected
const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r))
const recentLogs = logLines.slice(-5)
const recentLogs = useGatewayLogTail()
// Keep the tail pinned to the latest line as it streams.
const logScrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = logScrollRef.current
if (el) {
el.scrollTop = el.scrollHeight
}
}, [recentLogs])
return (
<div className="text-sm">
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
{inferenceReady ? (
<Activity className="size-3.5 text-primary" />
) : (
<AlertCircle className={cn('size-3.5', gatewayOpen ? 'text-amber-600' : 'text-destructive')} />
)}
<span className="font-medium">{copy.gateway}</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex min-w-0 flex-col gap-1 text-[0.7rem] leading-none">
<span className="flex items-center gap-1.5 font-medium">
<StatusDot tone={gatewayOpen ? 'good' : gatewayConnecting ? 'warn' : 'bad'} />
{connectionLabel}
</span>
<span className="flex items-center gap-1.5 text-muted-foreground">
<StatusDot tone={inferenceReady ? 'good' : gatewayOpen ? 'warn' : 'bad'} />
{inferenceLabel}
</span>
</div>
<div className="flex items-center">
<div className="flex shrink-0 items-center gap-0.5">
<Tip label={t.commandCenter.restartGateway}>
<Button
aria-label={t.commandCenter.restartGateway}
className="text-muted-foreground hover:text-foreground"
onClick={restart}
size="icon-xs"
variant="ghost"
>
<RefreshCw />
</Button>
</Tip>
<Tip label={copy.openSystem}>
<Button
aria-label={copy.openSystem}
className="text-muted-foreground hover:text-foreground"
onClick={onOpenSystem}
size="icon-sm"
onClick={openSystem}
size="icon-xs"
variant="ghost"
>
<LayoutDashboard />
@ -92,32 +175,29 @@ export function GatewayMenuPanel({
</div>
</div>
<div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
<div>{copy.connection(connectionLabel)}</div>
{inferenceStatus?.reason && <div className="mt-1 line-clamp-3">{inferenceStatus.reason}</div>}
</div>
{inferenceStatus?.reason && (
<div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
<div className="line-clamp-3">{inferenceStatus.reason}</div>
</div>
)}
{recentLogs.length > 0 && (
<div className="border-t border-border/50 px-3 py-2">
<SectionLabel>{copy.recentActivity}</SectionLabel>
<ul className="mt-1.5 space-y-0.5">
{recentLogs.map((line, index) => (
<Tip key={`${index}:${line}`} label={line.trim()}>
<li className="truncate font-mono text-[0.68rem] text-muted-foreground/85">
{trimLogLine(line) || '\u00A0'}
</li>
</Tip>
))}
</ul>
<Button
className="-ml-2 mt-1.5 font-medium text-muted-foreground"
onClick={onOpenSystem}
size="xs"
type="button"
variant="text"
>
{copy.viewAllLogs}
</Button>
<div className="px-3 py-2">
<div className="flex items-center justify-between gap-2">
<SectionLabel>{copy.recentActivity}</SectionLabel>
<Button
className="-mr-2 h-auto py-0 font-medium leading-none text-muted-foreground"
onClick={openSystem}
size="xs"
type="button"
variant="text"
>
{copy.viewAllLogs}
</Button>
</div>
<LogView className="mt-1.5 max-h-40 border-0 px-0" ref={logScrollRef}>
{recentLogs.map(trimLogLine).join('\n')}
</LogView>
</div>
)}

View file

@ -1,17 +1,15 @@
import { useEffect, useState } from 'react'
import { getLogs, getStatus } from '@/hermes'
import { getStatus } from '@/hermes'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import type { StatusResponse } from '@/types/hermes'
const REFRESH_MS = 15_000
const LOG_TAIL = 12
type GatewayRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
export function useStatusSnapshot(gatewayState: string | undefined, requestGateway: GatewayRequester) {
const [statusSnapshot, setStatusSnapshot] = useState<StatusResponse | null>(null)
const [gatewayLogLines, setGatewayLogLines] = useState<string[]>([])
const [inferenceStatus, setInferenceStatus] = useState<RuntimeReadinessResult | null>(null)
useEffect(() => {
@ -19,9 +17,8 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
const refresh = async () => {
try {
const [next, logs, inference] = await Promise.all([
const [next, inference] = await Promise.all([
getStatus(),
getLogs({ file: 'gui', lines: LOG_TAIL }).catch(() => ({ lines: [] })),
gatewayState === 'open'
? evaluateRuntimeReadiness(requestGateway).catch(error => ({
checksDisagree: false,
@ -37,7 +34,6 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
}
setStatusSnapshot(next)
setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean))
setInferenceStatus(inference)
} catch {
// Keep last snapshot through transient gateway flaps.
@ -53,5 +49,5 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
}
}, [gatewayState, requestGateway])
return { gatewayLogLines, inferenceStatus, statusSnapshot }
return { inferenceStatus, statusSnapshot }
}

View file

@ -43,7 +43,6 @@ interface StatusbarItemsOptions {
commandCenterOpen: boolean
extraLeftItems: readonly StatusbarItem[]
extraRightItems: readonly StatusbarItem[]
gatewayLogLines: readonly string[]
gatewayState: string
inferenceStatus: RuntimeReadinessResult | null
openAgents: () => void
@ -60,7 +59,6 @@ export function useStatusbarItems({
commandCenterOpen,
extraLeftItems,
extraRightItems,
gatewayLogLines,
gatewayState,
inferenceStatus,
openAgents,
@ -131,16 +129,16 @@ export function useStatusbarItems({
const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady)
const gatewayMenuContent = useMemo(
() => (
() => (close: () => void) => (
<GatewayMenuPanel
gatewayState={gatewayState}
inferenceStatus={inferenceStatus}
logLines={gatewayLogLines}
onClose={close}
onOpenSystem={() => openCommandCenterSection('system')}
statusSnapshot={statusSnapshot}
/>
),
[gatewayLogLines, gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot]
[gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot]
)
// The indicator must speak the same scope as the Spawn-tree panel it opens:
@ -235,6 +233,7 @@ export function useStatusbarItems({
const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart'
const base = copy.backendLabel(backendVersion ?? copy.unknown)
const behindHint =
!applying && behind > 0 ? ` (+${behind})` : !applying && updateAvailable ? ` (${copy.update})` : ''

View file

@ -1,4 +1,4 @@
import type { ComponentProps, ReactNode } from 'react'
import { type ComponentProps, type ReactNode, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@ -34,7 +34,8 @@ export interface StatusbarItem {
href?: string
menuAlign?: 'center' | 'end' | 'start'
menuClassName?: string
menuContent?: ReactNode
// A render fn receives a `close()` to dismiss the popover from inside the content.
menuContent?: ((close: () => void) => ReactNode) | ReactNode
menuItems?: readonly StatusbarMenuItem[]
onSelect?: (modifiers: StatusbarSelectModifiers) => void
title?: string
@ -88,6 +89,8 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
}
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
const [menuOpen, setMenuOpen] = useState(false)
const content = (
<>
{item.icon}
@ -99,7 +102,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
return (
<Tip label={item.title}>
<DropdownMenu>
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
<DropdownMenuTrigger asChild>
<button className={cn(STATUSBAR_ACTION_CLASS, item.className)} disabled={item.disabled} type="button">
{content}
@ -112,7 +115,9 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
sideOffset={8}
>
{item.menuContent
? item.menuContent
? typeof item.menuContent === 'function'
? item.menuContent(() => setMenuOpen(false))
: item.menuContent
: (item.menuItems ?? [])
.filter(menuItem => !menuItem.hidden)
.map(menuItem => (

View file

@ -1,7 +1,6 @@
import { useAuiState } from '@assistant-ui/react'
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { composerPanelCard } from '@/components/chat/composer-dock'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@ -19,13 +18,11 @@ const HOVER_CLOSE_MS = 140
const ROW_CLASS =
'relative flex w-full min-w-0 max-w-full cursor-pointer select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none'
const POPOVER_SHELL = cn(
'absolute right-full top-1/2 z-50 mr-1.5 max-h-[min(22rem,calc(100vh-8rem))] w-80 max-w-[min(20rem,calc(100vw-2rem))] -translate-y-1/2 overflow-x-hidden overflow-y-auto overscroll-contain p-1 text-popover-foreground transition-[opacity,transform] duration-100 ease-out group-hover/timeline:transition-none',
composerPanelCard,
// Solid fill — composerPanelCard is deliberately translucent; without this,
// directive chips in the transcript bleed through and look like popover overflow.
'bg-(--composer-fill)'
)
// Surface (border-color/bg/shadow/blur) comes from the shared
// `[data-slot='thread-timeline-popover']` rule in styles.css, so it's 1:1 with
// the dropdown/select/dialog menus. We only own layout + the border/radius here.
const POPOVER_SHELL =
'absolute right-full top-1/2 z-50 max-h-[min(22rem,calc(100vh-8rem))] w-80 max-w-[min(20rem,calc(100vw-2rem))] -translate-y-1/2 overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg border p-1 text-popover-foreground transition-[opacity,transform] duration-100 ease-out group-hover/timeline:transition-none'
function userPromptText(content: unknown): string {
if (typeof content === 'string') {
@ -149,6 +146,8 @@ export const ThreadTimeline: FC = () => {
const tickRefs = useRef<(HTMLSpanElement | null)[]>([])
const rowRefs = useRef<(HTMLButtonElement | null)[]>([])
// Hover sync: light the tick + its popover row, and scroll that row into view
// when the list overflows so the hovered prompt is always visible.
const paint = useCallback((index: number, on: boolean) => {
const tick = tickRefs.current[index]
@ -156,7 +155,12 @@ export const ThreadTimeline: FC = () => {
tick.style.opacity = on ? '1' : ''
}
rowRefs.current[index]?.classList.toggle('bg-(--ui-row-hover-background)', on)
const row = rowRefs.current[index]
row?.classList.toggle('bg-(--ui-row-hover-background)', on)
if (on) {
row?.scrollIntoView({ block: 'nearest' })
}
}, [])
const keepOpen = useCallback(() => {

View file

@ -52,7 +52,8 @@ export function SearchField({
className={cn(
// `field-sizing: content` grows the input to fit the placeholder/typed
// text, capped by the container's max-width — no awkward empty space.
'h-7 max-w-full bg-transparent text-sm text-foreground [field-sizing:content] placeholder:text-muted-foreground focus:outline-none',
// text-xs matches the form controls (Input/Select via controlVariants).
'h-7 max-w-full bg-transparent text-xs text-foreground [field-sizing:content] placeholder:text-muted-foreground focus:outline-none',
inputClassName
)}
onChange={event => onChange(event.target.value)}

View file

@ -1053,6 +1053,7 @@ export const en: Translations = {
nameHint: 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.',
title: 'Profiles',
count: count => `${count} ${count === 1 ? 'profile' : 'profiles'}`,
search: 'Search profiles...',
loading: 'Loading profiles...',
newProfile: 'New profile',
allProfiles: 'All profiles',
@ -1125,6 +1126,8 @@ export const en: Translations = {
cron: {
close: 'Close cron',
title: 'Scheduled jobs',
count: count => `${count} ${count === 1 ? 'job' : 'jobs'}`,
search: 'Search cron jobs...',
loading: 'Loading cron jobs...',
states: {

View file

@ -1171,6 +1171,7 @@ export const ja = defineLocale({
nameHint: '小文字、数字、ハイフン、アンダースコア。文字または数字で始める必要があります。',
title: 'プロファイル',
count: count => `${count} プロファイル`,
search: 'プロファイルを検索...',
loading: 'プロファイルを読み込み中...',
newProfile: '新しいプロファイル',
allProfiles: 'すべてのプロファイル',
@ -1244,6 +1245,8 @@ export const ja = defineLocale({
cron: {
close: 'Cron を閉じる',
title: 'スケジュール済みジョブ',
count: count => `${count} 件のジョブ`,
search: 'Cron ジョブを検索...',
loading: 'Cron ジョブを読み込み中...',
states: {

View file

@ -844,6 +844,7 @@ export interface Translations {
nameHint: string
title: string
count: (count: number) => string
search: string
loading: string
newProfile: string
allProfiles: string
@ -916,6 +917,8 @@ export interface Translations {
cron: {
close: string
title: string
count: (count: number) => string
search: string
loading: string
states: Record<string, string>

View file

@ -279,7 +279,8 @@ export const zhHant = defineLocale({
translucencyTitle: '視窗透明',
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
embedsTitle: '內嵌預覽',
embedsDesc: '豐富預覽會從第三方網站YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
embedsDesc:
'豐富預覽會從第三方網站YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
embedsAsk: '詢問',
embedsAlways: '一律',
embedsOff: '關閉',
@ -1126,6 +1127,7 @@ export const zhHant = defineLocale({
nameHint: '小寫字母、數字、連字號和底線。必須以字母或數字開頭。',
title: '設定檔',
count: count => `${count} 個設定檔`,
search: '搜尋設定檔…',
loading: '正在載入設定檔…',
newProfile: '新增設定檔',
allProfiles: '全部設定檔',
@ -1198,6 +1200,8 @@ export const zhHant = defineLocale({
cron: {
close: '關閉排程',
title: '排程工作',
count: count => `${count} 個工作`,
search: '搜尋排程工作…',
loading: '正在載入排程工作…',
states: {

View file

@ -370,7 +370,8 @@ export const zh: Translations = {
translucencyTitle: '窗口透明',
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
embedsTitle: '内嵌预览',
embedsDesc: '富预览会从第三方网站YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
embedsDesc:
'富预览会从第三方网站YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
embedsAsk: '询问',
embedsAlways: '总是',
embedsOff: '关闭',
@ -1233,6 +1234,7 @@ export const zh: Translations = {
nameHint: '小写字母、数字、连字符和下划线。必须以字母或数字开头。',
title: '配置档案',
count: count => `${count} 个配置档案`,
search: '搜索配置档案…',
loading: '正在加载配置档案…',
newProfile: '新建配置档案',
allProfiles: '全部配置档案',
@ -1305,6 +1307,8 @@ export const zh: Translations = {
cron: {
close: '关闭定时任务',
title: '定时任务',
count: count => `${count} 个任务`,
search: '搜索定时任务…',
loading: '正在加载定时任务…',
states: {

View file

@ -283,6 +283,16 @@
--dt-accent-foreground: var(--ui-text-primary);
--dt-border: var(--ui-stroke-secondary);
--dt-input: var(--ui-stroke-primary);
/* THE single knob for input-field borders: the resting alpha (% of the ring
color). Hover doubles it; focus/open go full. 0% = invisible at rest. */
--dt-input-border: 7%;
/* Knob for input-field background fill: alpha (% of --dt-card) across all
states. 100% = fully opaque, lower = translucent over the blurred chrome. */
--dt-input-bg: 0%;
/* Classic recessed "inset" a crisp 1px inner shadow at the TOP edge.
:root.dark bumps the alpha since a dark card swallows shadow. Removed on
focus (the focus border carries the state). */
--dt-input-inset: inset 0 1px 1px color-mix(in srgb, #000 10%, transparent);
--dt-ring: var(--ui-stroke-primary);
--dt-midground: var(--theme-midground);
--dt-composer-ring: var(--ui-base);
@ -405,6 +415,10 @@
--sidebar-edge-border: color-mix(in srgb, var(--ui-base) 12%, transparent);
--composer-ring-strength: 1.3;
--backdrop-invert-mul: 0;
/* Dark mode: a dark card needs a stronger black inset to show the recess. */
--dt-input-inset: inset 0 1px 1px color-mix(in srgb, #000 38%, transparent);
/* Dark needs a lighter resting border than light mode. */
--dt-input-border: 4%;
--ui-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
--ui-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
@ -686,7 +700,8 @@ button {
[data-slot='dropdown-menu-content'],
[data-slot='select-content'],
[data-slot='dialog-content'] {
[data-slot='dialog-content'],
[data-slot='thread-timeline-popover'] {
border-color: var(--ui-stroke-secondary);
background: color-mix(in srgb, var(--ui-bg-elevated) 96%, transparent);
box-shadow: var(--shadow-md);
@ -744,9 +759,8 @@ code {
}
/* Arc-style multicolor action surface (static, not animated). Reusable on any
Button via className. Unlayered so it beats Tailwind's bg-*/text-* variant
utilities. */
.btn-arc {
Button via className. Unlayered so it beats Tailwind's bg-*/
text-* variant utilities. */ .btn-arc {
background-image: linear-gradient(110deg, #5b6cff 0%, #8b5cf6 28%, #d946ef 58%, #fb7185 82%, #fb923c 100%);
color: #fff;
border-color: transparent;
@ -757,30 +771,27 @@ code {
}
/* Shared input chrome — mirrors composer hover/focus FX. Unlayered to beat Tailwind utilities. */
/* Border strength is driven by the single --dt-input-border alpha knob (× the
theme's tuned ring color); hover doubles it and focus triples it. Only the
border-color animates animating the translucent background over the window's
backdrop-blur flickers (badly on textareas), so the bg fill snaps instead.
(:focus is declared after :hover so a focused+hovered field shows focus.) */
.desktop-input-chrome {
--ring-pct: 18%;
--ring-fall: var(--dt-input);
background: color-mix(in srgb, var(--dt-card) 68%, transparent);
border-color: color-mix(
in srgb,
var(--dt-composer-ring) calc(var(--ring-pct) * var(--composer-ring-strength)),
var(--ring-fall)
);
box-shadow: none;
transition:
background-color 200ms ease-out,
border-color 200ms ease-out;
background: color-mix(in srgb, var(--dt-card) var(--dt-input-bg), transparent);
border-color: color-mix(in srgb, var(--dt-composer-ring) var(--dt-input-border), transparent);
box-shadow: var(--dt-input-inset);
transition: border-color 200ms ease-out;
}
.desktop-input-chrome:hover {
--ring-pct: 30%;
background: color-mix(in srgb, var(--dt-card) 86%, transparent);
border-color: color-mix(in srgb, var(--dt-composer-ring) calc(var(--dt-input-border) * 2), transparent);
}
.desktop-input-chrome:focus {
--ring-pct: 45%;
--ring-fall: transparent;
background: var(--dt-card);
/* `[data-state='open']` keeps the trigger looking focused while its dropdown is
open Radix moves focus into the list, so the trigger itself loses :focus. */
.desktop-input-chrome:focus,
.desktop-input-chrome[data-state='open'] {
border-color: var(--dt-composer-ring);
box-shadow: none;
outline: none;
}
@ -1514,8 +1525,12 @@ code {
width: 5.5rem;
height: 7rem;
border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
background:
radial-gradient(120% 90% at 32% 26%, color-mix(in srgb, var(--ui-accent) 14%, #fff) 0%, #f4ecd8 46%, #e4d3ad 100%);
background: radial-gradient(
120% 90% at 32% 26%,
color-mix(in srgb, var(--ui-accent) 14%, #fff) 0%,
#f4ecd8 46%,
#e4d3ad 100%
);
box-shadow:
inset -0.45rem -0.6rem 1.1rem color-mix(in srgb, #000 16%, transparent),
inset 0.35rem 0.4rem 0.7rem color-mix(in srgb, #fff 70%, transparent),
@ -1568,7 +1583,11 @@ code {
height: 0.8rem;
border-radius: 50%;
/* Lighter on light backgrounds (~20% less ink); dark mode keeps it grounded. */
background: radial-gradient(circle, color-mix(in srgb, #000 var(--pet-egg-shadow-ink, 26%), transparent) 0%, transparent 72%);
background: radial-gradient(
circle,
color-mix(in srgb, #000 var(--pet-egg-shadow-ink, 26%), transparent) 0%,
transparent 72%
);
animation: pet-egg-shadow 2.4s ease-in-out infinite;
}

View file

@ -4955,8 +4955,11 @@ class BasePlatformAdapter(ABC):
),
metadata=_thread_metadata,
)
except Exception:
pass # Last resort — don't let error reporting crash the handler
except Exception as notify_err:
logger.error(
"[%s] Failed to send error notification to user: %s",
self.name, notify_err, exc_info=True,
) # Last resort — don't let error reporting crash the handler
finally:
# Stop typing before any deferred callback work. Post-delivery
# callbacks may perform platform I/O; a stuck callback must not

View file

@ -938,13 +938,34 @@ class WebhookAdapter(BasePlatformAdapter):
success=False, error="Missing repo or pr_number"
)
# --- Input validation (prevent CLI argument injection) ---
# pr_number must be a positive integer.
try:
pr_int = int(pr_number)
if pr_int <= 0:
raise ValueError("non-positive")
except (ValueError, TypeError):
logger.error(
"[webhook] invalid pr_number: %r", pr_number
)
return SendResult(
success=False, error="Invalid pr_number"
)
# repo must match owner/name (alphanumeric, hyphens, underscores, dots).
if not re.fullmatch(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+", repo):
logger.error("[webhook] invalid repo format: %r", repo)
return SendResult(
success=False, error="Invalid repo format"
)
try:
result = subprocess.run(
[
"gh",
"pr",
"comment",
str(pr_number),
str(pr_int),
"--repo",
repo,
"--body",

View file

@ -10910,8 +10910,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
except Exception:
logger.debug("Failed to persist inbound user message after agent exception", exc_info=True)
error_type = type(e).__name__
error_detail = str(e)[:300] if str(e) else "no details available"
# Log full details server-side only; never expose raw exception
# types or messages to end users (info-leakage risk).
status_hint = ""
status_code = getattr(e, "status_code", None)
_hist_len = len(history) if 'history' in locals() else 0
@ -10955,9 +10955,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
elif status_code == 400:
status_hint = " The request was rejected by the API."
return (
f"Sorry, I encountered an error ({error_type}).\n"
f"{error_detail}\n"
f"{status_hint}"
f"Sorry, I encountered an unexpected error.{status_hint}\n"
"Try again or use /reset to start a fresh session."
)
finally:

View file

@ -3649,6 +3649,83 @@ OPTIONAL_ENV_VARS = {
"category": "tool",
},
# ── Hindsight ──
"HINDSIGHT_API_KEY": {
"description": "Hindsight API key for graph-aware persistent memory",
"prompt": "Hindsight API key",
"url": "https://hindsight.vectorize.io",
"tools": ["hindsight_recall"],
"password": True,
"category": "tool",
},
"HINDSIGHT_API_URL": {
"description": "Base URL for the Hindsight API (default: https://api.hindsight.vectorize.io)",
"prompt": "Hindsight API URL",
"category": "tool",
"advanced": True,
},
# ── Supermemory ──
"SUPERMEMORY_API_KEY": {
"description": "Supermemory API key for conversation-scoped persistent memory",
"prompt": "Supermemory API key",
"url": "https://supermemory.ai",
"tools": ["supermemory_search"],
"password": True,
"category": "tool",
},
# ── Mem0 ──
"MEM0_API_KEY": {
"description": "Mem0 Platform API key for semantic persistent memory",
"prompt": "Mem0 API key",
"url": "https://app.mem0.ai",
"tools": ["mem0_search"],
"password": True,
"category": "tool",
},
# ── RetainDB ──
"RETAINDB_API_KEY": {
"description": "RetainDB API key for persistent memory",
"prompt": "RetainDB API key",
"url": "https://retaindb.com",
"tools": ["retaindb_search"],
"password": True,
"category": "tool",
},
"RETAINDB_BASE_URL": {
"description": "Base URL for self-hosted RetainDB instances (default: https://api.retaindb.com)",
"prompt": "RetainDB base URL",
"category": "tool",
"advanced": True,
},
# ── ByteRover ──
"BRV_API_KEY": {
"description": "ByteRover API key (optional, for cloud sync — local-first by default)",
"prompt": "ByteRover API key",
"url": "https://app.byterover.dev",
"tools": ["brv_query"],
"password": True,
"category": "tool",
},
# ── OpenViking ──
"OPENVIKING_API_KEY": {
"description": "OpenViking API key (leave blank for local dev mode)",
"prompt": "OpenViking API key",
"tools": ["viking_search"],
"password": True,
"category": "tool",
},
"OPENVIKING_ENDPOINT": {
"description": "OpenViking server URL (default: http://127.0.0.1:1933)",
"prompt": "OpenViking endpoint",
"category": "tool",
"advanced": True,
},
# ── Langfuse observability ──
"HERMES_LANGFUSE_PUBLIC_KEY": {
"description": "Langfuse project public key (pk-lf-...)",
@ -6494,6 +6571,11 @@ def load_env() -> Dict[str, str]:
for line in lines:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
# Strip the bash-compatible ``export `` prefix so lines like
# ``export API_KEY=...`` parse as ``API_KEY`` rather than being
# stored under the wrong key ``"export API_KEY"`` (#6659).
if line.startswith('export '):
line = line[7:]
key, _, value = line.partition('=')
env_vars[key.strip()] = _parse_env_value(value)

View file

@ -828,6 +828,8 @@ def _has_any_provider_configured() -> bool:
line = line.strip()
if line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[7:]
key, _, val = line.partition("=")
val = val.strip().strip("'\"")
if key.strip() in provider_env_vars and val:
@ -11147,7 +11149,7 @@ def cmd_profile(args):
remove = getattr(args, "remove", False)
custom_name = getattr(args, "alias_name", None)
from hermes_cli.profiles import profile_exists
from hermes_cli.profiles import profile_exists, validate_alias_name
if not profile_exists(name):
print(f"Error: Profile '{name}' does not exist.")
@ -11155,6 +11157,12 @@ def cmd_profile(args):
alias_name = custom_name or name
try:
validate_alias_name(alias_name)
except ValueError as exc:
print(f"Error: {exc}")
sys.exit(1)
if remove:
if remove_wrapper_script(alias_name):
print(f"✓ Removed alias '{alias_name}'")

View file

@ -328,6 +328,22 @@ def validate_profile_name(name: str) -> None:
)
def validate_alias_name(name: str) -> None:
"""Raise ``ValueError`` if *name* is not a safe wrapper-alias identifier.
The alias is used verbatim as a filename under :func:`_get_wrapper_dir`
(``~/.local/bin``), so it must be a single safe command name with no path
separators or traversal segments otherwise a value like ``../../.bashrc``
would escape the wrapper directory and clobber arbitrary user files. We
reuse the profile id regex, which already forbids ``/``, ``.``, and ``..``.
"""
if not _PROFILE_ID_RE.match(name):
raise ValueError(
f"Invalid alias name {name!r}. Must match "
f"[a-z0-9][a-z0-9_-]{{0,63}}"
)
def get_profile_dir(name: str) -> Path:
"""Resolve a profile name to its HERMES_HOME directory."""
canon = normalize_profile_name(name)
@ -351,9 +367,14 @@ def profile_exists(name: str) -> bool:
def check_alias_collision(name: str) -> Optional[str]:
"""Return a human-readable collision message, or None if the name is safe.
Checks: reserved names, hermes subcommands, existing binaries in PATH.
Checks: alias-name validity, reserved names, hermes subcommands, existing
binaries in PATH.
"""
canon = normalize_profile_name(name)
try:
validate_alias_name(canon)
except ValueError as exc:
return str(exc)
if canon in _RESERVED_NAMES:
return f"'{canon}' is a reserved name"
if canon in _HERMES_SUBCOMMANDS:
@ -403,6 +424,9 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
"""
canon = normalize_profile_name(name)
profile = normalize_profile_name(target) if target else canon
# The alias is used verbatim as a filename under the wrapper dir; reject
# any value that isn't a single safe identifier so it can't traverse out.
validate_alias_name(canon)
wrapper_dir = _get_wrapper_dir()
try:
wrapper_dir.mkdir(parents=True, exist_ok=True)
@ -435,6 +459,12 @@ def remove_wrapper_script(name: str) -> bool:
"""Remove the wrapper script for a profile. Returns True if removed."""
wrapper_dir = _get_wrapper_dir()
canon = normalize_profile_name(name)
# A traversal-shaped name could point unlink() at a file outside the
# wrapper dir; refuse it rather than acting on an arbitrary path.
try:
validate_alias_name(canon)
except ValueError:
return False
is_windows = sys.platform == "win32"
# Check both the extensionless path (POSIX) and .bat (Windows)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View file

@ -4429,9 +4429,19 @@ class AIAgent:
return True
return False
# 20 MB base64 ≈ 15 MB decoded image — generous but prevents OOM from an
# oversized data: URL (a 100 MB+ payload creates ~275 MB of memory pressure,
# and gateway users sharing the same process can trivially OOM it).
_MAX_DATA_URL_BASE64_BYTES = 20 * 1024 * 1024
@staticmethod
def _materialize_data_url_for_vision(image_url: str) -> tuple[str, Optional[Path]]:
header, _, data = str(image_url or "").partition(",")
if len(data) > AIAgent._MAX_DATA_URL_BASE64_BYTES:
logger.warning(
"data-URL payload too large (%d bytes), skipping", len(data)
)
return "", None
mime = "image/jpeg"
if header.startswith("data:"):
mime_part = header[len("data:"):].split(";", 1)[0].strip()

View file

@ -45,6 +45,8 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"256073454+Kolektori@users.noreply.github.com": "Kolektori", # PR #6436 salvage (require approval for host-bound Docker commands; container guard fast-path)
"41764686+LIC99@users.noreply.github.com": "LIC99", # PR #4682 salvage (warn + default to manual on unknown approvals.mode; #4261)
"carlosmcejas@gmail.com": "cmcejas", # PR #41188 salvage (early Telegram auth gate before event build/observe; #40863)
"ha-agent@homelab.4410.us": "oreoluwa", # PR #49845 salvage (skip preflight content-type probe for OAuth MCP servers so OAuth discovery runs; Akiflow/Hospitable)
"prathamesh290504@gmail.com": "PRATHAMESH75", # PR #37550 salvage (ExecStopPost cgroup-orphan reaper to unblock systemd restart; #37454)
@ -971,6 +973,7 @@ AUTHOR_MAP = {
"oluwadareab12@gmail.com": "oluwadareab12",
"simon@simonmarcus.org": "simon-marcus",
"xowiekk@gmail.com": "Xowiek",
"gutslabsxyz@gmail.com": "Gutslabs",
"1243352777@qq.com": "zons-zhaozhy",
"e.silacandmr@gmail.com": "Es1la",
"51599529+stephen0110@users.noreply.github.com": "stephen0110",

View file

@ -492,6 +492,79 @@ class TestWebUrlsNotRedacted:
assert "dbpass" not in result
class TestBareTokenUserinfoRedaction:
"""Regression tests for #6396 — a bare credential in URL userinfo
(``scheme://TOKEN@host``, no ``user:pass`` colon) is redacted. This is the
git-remote-with-embedded-password shape. The colon form ``user:pass@`` and
query-string tokens are deliberately left to pass through (#34029) so
magic-link / OAuth round-trip skills keep working see
TestWebUrlsNotRedacted for those invariants.
"""
def test_git_remote_bare_password_redacted(self):
"""Exact bug scenario: password in a git remote URL."""
text = (
"git remote set-url origin "
"https://MYPASSWORDWASDISLAYEDHERE@github.com/unclehowell/FCUK.git"
)
result = redact_sensitive_text(text)
assert "MYPASSWORDWASDISLAYEDHERE" not in result
assert "@github.com" in result
assert "unclehowell/FCUK.git" in result
def test_ssh_bare_token_redacted(self):
text = "ssh://longtoken1234567@gitlab.com/project.git"
result = redact_sensitive_text(text)
assert "longtoken1234567" not in result
assert "@gitlab.com" in result
def test_ftp_bare_token_redacted(self):
text = "ftp://ftptoken123456@ftp.example.com/files"
result = redact_sensitive_text(text)
assert "ftptoken123456" not in result
def test_bare_token_with_query_redacts_token_only(self):
text = "https://abcdef1234567@host.com/path?foo=bar"
result = redact_sensitive_text(text)
assert "abcdef1234567" not in result
assert "?foo=bar" in result
def test_user_pass_form_still_passes_through(self):
"""The ``user:pass@`` colon form must NOT be redacted (#34029)."""
text = "URL: https://user:supersecretpw@host.example.com/path"
assert redact_sensitive_text(text) == text
def test_short_username_not_redacted(self):
"""Short userinfo (git, admin, deploy) below the 8-char floor passes."""
for text in (
"https://git@github.com/user/repo.git",
"https://admin@example.com/x",
"https://deploy@host.com/y",
):
assert redact_sensitive_text(text) == text
def test_email_in_path_not_redacted(self):
"""An ``@`` in a path/query is not userinfo — the token class stops at
``/``, so emails after the first slash are never treated as a credential."""
for text in (
"https://example.com/search?q=user@example.com",
"https://example.com/users/john@doe.com/profile",
):
assert redact_sensitive_text(text) == text
def test_plain_url_unchanged(self):
text = "https://github.com/user/repo.git"
assert redact_sensitive_text(text) == text
def test_long_bare_token_preserves_head_tail(self):
token = "abcdef" + "x" * 20 + "wxyz"
text = f"https://{token}@github.com/u/r.git"
result = redact_sensitive_text(text)
assert token not in result
assert "abcdef" in result # head preserved
assert "wxyz" in result # tail preserved
class TestFormBodyRedaction:
"""Form-urlencoded body redaction (k=v&k=v with no other text)."""

View file

@ -700,6 +700,49 @@ class TestOptionalEnvVarsRegistry:
assert "HERMES_MAX_ITERATIONS" not in OPTIONAL_ENV_VARS
class TestMemoryProviderEnvVarsRegistry:
"""Every memory provider that reads an API key from the environment must
have that key catalogued in OPTIONAL_ENV_VARS so the dashboard Keys page
and `hermes setup` surface it (previously only Honcho was listed, leaving
Hindsight/Supermemory/Mem0/RetainDB/ByteRover/OpenViking invisible).
This is a behavior contract, not a snapshot: it asserts each provider's
primary credential key is present, tool-categorised, and password-masked
not a frozen count of entries.
"""
# provider primary-credential env key -> the tool-call name it powers.
MEMORY_PROVIDER_KEYS = {
"HONCHO_API_KEY": "honcho_context",
"HINDSIGHT_API_KEY": "hindsight_recall",
"SUPERMEMORY_API_KEY": "supermemory_search",
"MEM0_API_KEY": "mem0_search",
"RETAINDB_API_KEY": "retaindb_search",
"BRV_API_KEY": "brv_query",
"OPENVIKING_API_KEY": "viking_search",
}
def test_memory_provider_keys_are_catalogued(self):
from hermes_cli.config import OPTIONAL_ENV_VARS
missing = [k for k in self.MEMORY_PROVIDER_KEYS if k not in OPTIONAL_ENV_VARS]
assert not missing, f"memory provider keys missing from OPTIONAL_ENV_VARS: {missing}"
def test_memory_provider_keys_are_tool_category(self):
from hermes_cli.config import OPTIONAL_ENV_VARS
for key in self.MEMORY_PROVIDER_KEYS:
assert OPTIONAL_ENV_VARS[key]["category"] == "tool", key
def test_memory_provider_keys_are_password_masked(self):
from hermes_cli.config import OPTIONAL_ENV_VARS
for key in self.MEMORY_PROVIDER_KEYS:
assert OPTIONAL_ENV_VARS[key].get("password") is True, key
def test_memory_provider_keys_advertise_their_tool(self):
from hermes_cli.config import OPTIONAL_ENV_VARS
for key, tool in self.MEMORY_PROVIDER_KEYS.items():
assert tool in OPTIONAL_ENV_VARS[key].get("tools", []), key
class TestConfigMigrationSecretPrompts:
def test_required_secret_env_prompt_uses_masked_prompt(self, tmp_path, monkeypatch):
from hermes_cli import config as cfg_mod

View file

@ -0,0 +1,121 @@
"""Tests for ``export `` prefix handling in the hand-rolled .env parsers.
Bash-compatible .env files commonly prefix lines with ``export `` (users
copy-paste from shell profiles, cloud provider docs, tutorials). The three
hand-rolled parsers ``hermes_cli.config.load_env``,
``hermes_cli.main._has_any_provider_configured``, and
``tools.skills_tool.load_env`` split on ``line.partition("=")`` and must
strip the ``export `` prefix first, otherwise ``export API_KEY=sk-...`` is
stored under the wrong key ``"export API_KEY"`` and the real key is lost
(setup wizard re-triggers, providers undetected, skill env passthrough drops
the var). See PR #6659.
These assert the behavior contract (prefix stripped canonical key resolves),
not the literal parser source.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from unittest.mock import patch
def _write_env(path: Path, contents: str) -> None:
path.write_text(contents, encoding="utf-8")
def test_config_load_env_strips_export_prefix(tmp_path):
from hermes_cli.config import invalidate_env_cache, load_env
env_path = tmp_path / ".env"
_write_env(
env_path,
'export OPENAI_API_KEY=sk-export-123\n'
'export OPENROUTER_API_KEY="sk-or-456"\n'
'ANTHROPIC_API_KEY=sk-plain-789\n',
)
invalidate_env_cache()
try:
with patch("hermes_cli.config.get_env_path", return_value=env_path):
env = load_env()
finally:
invalidate_env_cache()
# Canonical keys resolve, export-prefixed wrong keys never appear.
assert env["OPENAI_API_KEY"] == "sk-export-123"
assert env["OPENROUTER_API_KEY"] == "sk-or-456"
assert env["ANTHROPIC_API_KEY"] == "sk-plain-789"
assert "export OPENAI_API_KEY" not in env
def test_config_load_env_does_not_mangle_non_export(tmp_path):
"""A bare 'export' word without trailing space is not a prefix."""
from hermes_cli.config import invalidate_env_cache, load_env
env_path = tmp_path / ".env"
_write_env(env_path, "PLAIN_KEY=val1\nexportNOSPACE=val2\nexport REAL=val3\n")
invalidate_env_cache()
try:
with patch("hermes_cli.config.get_env_path", return_value=env_path):
env = load_env()
finally:
invalidate_env_cache()
assert env["PLAIN_KEY"] == "val1"
# No trailing space → NOT an export prefix; the key stays intact.
assert env["exportNOSPACE"] == "val2"
assert env["REAL"] == "val3"
assert "export REAL" not in env
def test_skills_tool_load_env_strips_export_prefix(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / ".env").write_text(
"export SOME_SKILL_KEY=skillval\nPLAIN=plainval\n", encoding="utf-8"
)
# skills_tool.load_env reads get_hermes_home()/.env directly.
import importlib
import tools.skills_tool as skills_tool
importlib.reload(skills_tool)
with patch.object(skills_tool, "get_hermes_home", return_value=tmp_path):
env = skills_tool.load_env()
assert env["SOME_SKILL_KEY"] == "skillval"
assert env["PLAIN"] == "plainval"
assert "export SOME_SKILL_KEY" not in env
def test_has_any_provider_configured_with_export_prefix(tmp_path, monkeypatch):
"""An export-prefixed provider key in .env counts as configured.
Exercises the .env-reading branch of _has_any_provider_configured by
blanking provider creds from the process environment first, so detection
depends solely on parsing the file.
"""
import importlib
# Blank any provider-shaped creds so os.environ short-circuit can't mask
# the .env parse path.
for key in list(__import__("os").environ):
if key.endswith(("_API_KEY", "_TOKEN")) and key != "BWS_ACCESS_TOKEN":
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / ".env").write_text(
"export OPENAI_API_KEY=sk-export-only-123\n", encoding="utf-8"
)
import hermes_cli.main as hmain
importlib.reload(hmain)
# get_env_path() derives from HERMES_HOME (set above) → tmp_path/.env, so
# no patching is needed. Re-clear os.environ provider keys that
# load_hermes_dotenv may have populated at import/reload time, forcing the
# function down its .env-reading branch.
for key in list(__import__("os").environ):
if key.endswith(("_API_KEY", "_TOKEN")) and key != "BWS_ACCESS_TOKEN":
monkeypatch.delenv(key, raising=False)
assert hmain._has_any_provider_configured() is True

View file

@ -329,7 +329,7 @@ class TestTerminalToolGatewayLifecycleGuard:
return {"output": "Active: running", "returncode": 0}
self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=True)
monkeypatch.setattr(tt, "_check_all_guards", lambda cmd, env: {"approved": True})
monkeypatch.setattr(tt, "_check_all_guards", lambda cmd, env, **kwargs: {"approved": True})
result = json.loads(tt.terminal_tool(command="systemctl status nginx"))
@ -349,7 +349,7 @@ class TestTerminalToolGatewayLifecycleGuard:
return {"output": "restarting...", "returncode": 0}
self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=False)
monkeypatch.setattr(tt, "_check_all_guards", lambda cmd, env: {"approved": True})
monkeypatch.setattr(tt, "_check_all_guards", lambda cmd, env, **kwargs: {"approved": True})
result = json.loads(tt.terminal_tool(command="systemctl restart hermes-gateway"))

View file

@ -26,6 +26,9 @@ from hermes_cli.profiles import (
get_active_profile_name,
resolve_profile_env,
check_alias_collision,
create_wrapper_script,
remove_wrapper_script,
validate_alias_name,
rename_profile,
export_profile,
import_profile,
@ -769,6 +772,14 @@ class TestAliasCollision:
result = check_alias_collision("mybot")
assert result is None # our own wrapper, safe to overwrite
def test_traversal_alias_rejected_before_path_lookup(self, profile_env):
"""A path-traversal alias is rejected without ever shelling out to which/where."""
with patch("subprocess.run") as mock_run:
result = check_alias_collision("../../.bashrc")
assert result is not None
assert "invalid alias name" in result.lower()
mock_run.assert_not_called()
# ===================================================================
# TestWrapperScript
@ -850,6 +861,53 @@ class TestWrapperScript:
assert "#!/bin/sh" not in content
# ===================================================================
# TestWrapperScriptSecurity — path-traversal hardening
# ===================================================================
class TestWrapperScriptSecurity:
"""A crafted alias name must not escape the wrapper directory."""
def test_validate_alias_name_rejects_traversal(self):
with pytest.raises(ValueError, match="Invalid alias name"):
validate_alias_name("../../.bashrc")
def test_validate_alias_name_rejects_absolute_path(self, tmp_path):
with pytest.raises(ValueError, match="Invalid alias name"):
validate_alias_name(str(tmp_path / "evil"))
def test_validate_alias_name_accepts_safe_identifier(self):
validate_alias_name("mybot") # does not raise
def test_create_wrapper_rejects_traversal(self, profile_env):
sentinel = profile_env / ".bashrc"
sentinel.write_text("keep", encoding="utf-8")
with pytest.raises(ValueError, match="Invalid alias name"):
create_wrapper_script("../../.bashrc", target="coder")
# The traversal target was not touched.
assert sentinel.read_text(encoding="utf-8") == "keep"
def test_create_wrapper_rejects_absolute_path(self, profile_env, tmp_path):
target = tmp_path / "abs-wrapper"
with pytest.raises(ValueError, match="Invalid alias name"):
create_wrapper_script(str(target))
assert not target.exists()
def test_remove_wrapper_rejects_traversal(self, profile_env):
sentinel = profile_env / ".bashrc"
sentinel.write_text("keep", encoding="utf-8")
assert remove_wrapper_script("../../.bashrc") is False
assert sentinel.read_text(encoding="utf-8") == "keep"
def test_legit_alias_stays_inside_wrapper_dir(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
from hermes_cli.profiles import _get_wrapper_dir
wrapper = create_wrapper_script("mybot", target="coder")
assert wrapper is not None
assert wrapper.resolve().is_relative_to(_get_wrapper_dir().resolve())
assert 'hermes -p coder "$@"' in wrapper.read_text()
# ===================================================================
# TestFindAliasForProfile — display-side reverse lookup
# ===================================================================

View file

@ -12,6 +12,7 @@ import tools.approval as approval_module
from hermes_constants import get_hermes_home
from tools.approval import (
_get_approval_mode,
_normalize_approval_mode,
_smart_approve,
approve_session,
detect_dangerous_command,
@ -30,6 +31,27 @@ class TestApprovalModeParsing:
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"mode": "off"}}):
assert _get_approval_mode() == "off"
def test_valid_modes_pass_through(self):
assert _normalize_approval_mode("manual") == "manual"
assert _normalize_approval_mode("smart") == "smart"
assert _normalize_approval_mode("off") == "off"
def test_valid_mode_is_case_insensitive_and_trimmed(self):
assert _normalize_approval_mode(" SMART ") == "smart"
def test_unknown_mode_defaults_to_manual_with_warning(self):
with mock_patch.object(approval_module.logger, "warning") as warn:
assert _normalize_approval_mode("auto") == "manual"
warn.assert_called_once()
def test_empty_string_defaults_to_manual_without_warning(self):
with mock_patch.object(approval_module.logger, "warning") as warn:
assert _normalize_approval_mode("") == "manual"
warn.assert_not_called()
def test_yaml_bool_true_maps_to_manual(self):
assert _normalize_approval_mode(True) == "manual"
class TestSmartApproval:
def test_smart_approval_uses_call_llm(self):

View file

@ -0,0 +1,91 @@
"""Tests for cgroup resource-limit gating in the docker backend.
On hosts where the cgroup v2 cpu/memory/pids controllers are not delegated
(e.g. unprivileged Proxmox LXCs), passing ``--cpus``/``--memory``/``--pids-limit``
to ``docker run`` fails every container start with OCI runtime error / exit 126.
``_cgroup_limits_available`` probes once and the resource flags are gated on it,
so the sandbox degrades gracefully instead of failing.
"""
import subprocess
import pytest
import tools.environments.docker as docker_env
@pytest.fixture(autouse=True)
def _reset_cgroup_cache():
"""The probe result is cached in a module-level global; reset per test."""
docker_env._cgroup_limits_ok = None
yield
docker_env._cgroup_limits_ok = None
def test_pids_limit_not_in_base_security_args():
"""``--pids-limit`` must NOT be hardcoded in the static security args.
It requires the pids cgroup controller and is gated on the probe instead.
"""
assert "--pids-limit" not in docker_env._BASE_SECURITY_ARGS
def test_probe_returns_true_when_container_starts(monkeypatch):
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
captured = {}
def _run(cmd, *a, **k):
captured["cmd"] = cmd
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(docker_env.subprocess, "run", _run)
assert docker_env._cgroup_limits_available("hermes-agent:latest") is True
# Probes all three controllers together against the real sandbox image.
assert "--cpus" in captured["cmd"]
assert "--memory" in captured["cmd"]
assert "--pids-limit" in captured["cmd"]
assert "hermes-agent:latest" in captured["cmd"]
def test_probe_returns_false_and_warns_on_oci_error(monkeypatch, caplog):
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
def _run(cmd, *a, **k):
return subprocess.CompletedProcess(
cmd, 126, stdout="",
stderr="crun: controller `pids` is not available",
)
monkeypatch.setattr(docker_env.subprocess, "run", _run)
with caplog.at_level("WARNING"):
assert docker_env._cgroup_limits_available("img") is False
assert "Cgroup resource limits" in caplog.text
def test_probe_returns_false_when_no_docker(monkeypatch):
monkeypatch.setattr(docker_env, "find_docker", lambda: None)
assert docker_env._cgroup_limits_available("img") is False
def test_probe_returns_false_on_empty_image(monkeypatch):
"""An empty image string must not be probed (would be a malformed run)."""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(
docker_env.subprocess, "run",
lambda *a, **k: pytest.fail("should not probe with empty image"),
)
assert docker_env._cgroup_limits_available("") is False
def test_probe_result_is_cached(monkeypatch):
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
calls = []
def _run(cmd, *a, **k):
calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(docker_env.subprocess, "run", _run)
docker_env._cgroup_limits_available("img")
docker_env._cgroup_limits_available("img")
docker_env._cgroup_limits_available("img")
assert len(calls) == 1 # probe runs once, then cached

View file

@ -11,7 +11,13 @@ def _mock_subprocess_run(monkeypatch):
"""Mock subprocess.run to intercept docker run -d and docker version calls.
Returns a list of captured (cmd, kwargs) tuples for inspection.
Pre-seeds the cgroup-limit probe cache to ``True`` so the throwaway probe
container (a ``docker run ... sleep 0``) does not run and pollute the
captured call list these tests inspect the real sandbox-start ``run``.
Tests that exercise the probe itself live in test_docker_cgroup_limits.py.
"""
docker_env._cgroup_limits_ok = True
calls = []
def _run(cmd, **kwargs):

View file

@ -301,3 +301,98 @@ class TestHostPrefixList:
f"Host path {host_path!r} should be rejected as a container "
"cwd but was accepted."
)
# =========================================================================
# Test 7: Host-bound Docker sandboxes must not bypass dangerous-command
# approval. Isolated Docker keeps the container fast-path; once a host path
# is bind-mounted into the container, a command like `rm -rf /workspace` can
# reach real host files, so it goes through the normal approval flow.
# (PR #6436, @Kolektori)
# =========================================================================
class TestDockerHostBindApproval:
"""Docker host bind mounts disable the container approval fast-path."""
def test_docker_host_access_detection(self):
"""_docker_has_host_access flags bind-mounted host paths only."""
# Isolated docker (no host binds) -> not host access.
assert _tt_mod._docker_has_host_access(
{"env_type": "docker", "docker_volumes": [],
"host_cwd": None, "docker_mount_cwd_to_workspace": False}) is False
# Host-path bind mount -> host access.
assert _tt_mod._docker_has_host_access(
{"env_type": "docker", "docker_volumes": ["/tmp:/hosttmp"]}) is True
# Named volume (not a host path) -> not host access.
assert _tt_mod._docker_has_host_access(
{"env_type": "docker", "docker_volumes": ["myvol:/data"]}) is False
# cwd auto-mount flag -> host access.
assert _tt_mod._docker_has_host_access(
{"env_type": "docker", "host_cwd": "/home/u/p",
"docker_mount_cwd_to_workspace": True}) is True
# Windows host path -> host access.
assert _tt_mod._docker_has_host_access(
{"env_type": "docker", "docker_volumes": ["C:\\Users:/data"]}) is True
# Other container backends never report host access.
assert _tt_mod._docker_has_host_access(
{"env_type": "modal", "docker_volumes": ["/tmp:/x"]}) is False
def test_should_skip_container_guards(self):
"""Docker skips only when isolated; other sandboxes always skip."""
import tools.approval as A
assert A._should_skip_container_guards("docker", has_host_access=False) is True
assert A._should_skip_container_guards("docker", has_host_access=True) is False
assert A._should_skip_container_guards("modal", has_host_access=True) is True
assert A._should_skip_container_guards("singularity") is True
assert A._should_skip_container_guards("daytona") is True
assert A._should_skip_container_guards("local") is False
def test_isolated_docker_keeps_fast_path(self, monkeypatch):
"""Isolated Docker still bypasses dangerous-command approval."""
import tools.approval as A
monkeypatch.setenv("HERMES_EXEC_ASK", "1")
monkeypatch.setattr(
"tools.tirith_security.check_command_security",
lambda _c: {"action": "allow", "findings": [], "summary": ""})
res = A.check_all_command_guards("rm -rf /workspace", "docker",
has_host_access=False)
assert res["approved"] is True
def test_host_bound_docker_requires_approval(self, monkeypatch):
"""Host-bound Docker dangerous command escalates instead of bypassing."""
import tools.approval as A
monkeypatch.setenv("HERMES_EXEC_ASK", "1")
monkeypatch.setattr(
"tools.tirith_security.check_command_security",
lambda _c: {"action": "allow", "findings": [], "summary": ""})
res = A.check_all_command_guards("rm -rf /workspace", "docker",
has_host_access=True)
# Must NOT take the silent container fast-path.
assert res.get("approved") is not True
assert res.get("status") == "pending_approval"
def test_execute_code_isolated_docker_keeps_fast_path(self, monkeypatch):
"""Isolated Docker execute_code still bypasses the guard."""
import tools.approval as A
monkeypatch.setenv("HERMES_EXEC_ASK", "1")
res = A.check_execute_code_guard("import os", "docker",
has_host_access=False)
assert res["approved"] is True
def test_execute_code_host_bound_docker_requires_approval(self, monkeypatch):
"""Host-bound Docker execute_code does not get the container fast-path."""
import tools.approval as A
monkeypatch.setenv("HERMES_EXEC_ASK", "1")
res = A.check_execute_code_guard(
"import os; os.system('rm -rf /workspace')", "docker",
has_host_access=True)
assert res.get("approved") is not True
assert res.get("status") == "pending_approval"
def test_execute_code_vercel_sandbox_always_skips(self, monkeypatch):
"""vercel_sandbox has no host-bind concept and stays always-skipped."""
import tools.approval as A
monkeypatch.setenv("HERMES_EXEC_ASK", "1")
res = A.check_execute_code_guard("import os", "vercel_sandbox",
has_host_access=True)
assert res["approved"] is True

View file

@ -34,7 +34,7 @@ def test_foreground_command_uses_registered_task_cwd_for_existing_environment(mo
monkeypatch.setattr(
terminal_tool,
"_check_all_guards",
lambda command, env_type: {"approved": True},
lambda command, env_type, **kwargs: {"approved": True},
)
result = json.loads(terminal_tool.terminal_tool(command="pwd", task_id=task_id))
@ -61,7 +61,7 @@ def test_explicit_workdir_still_wins_over_registered_task_cwd(monkeypatch):
monkeypatch.setattr(
terminal_tool,
"_check_all_guards",
lambda command, env_type: {"approved": True},
lambda command, env_type, **kwargs: {"approved": True},
)
result = json.loads(
@ -98,7 +98,7 @@ def test_foreground_command_prefers_live_env_cwd_over_init_time_cwd(monkeypatch)
monkeypatch.setattr(
terminal_tool,
"_check_all_guards",
lambda command, env_type: {"approved": True},
lambda command, env_type, **kwargs: {"approved": True},
)
result = json.loads(terminal_tool.terminal_tool(command="pwd", task_id=task_id))
@ -136,7 +136,7 @@ def test_background_command_prefers_live_env_cwd_over_init_time_cwd(monkeypatch)
monkeypatch.setattr(
terminal_tool,
"_check_all_guards",
lambda command, env_type: {"approved": True},
lambda command, env_type, **kwargs: {"approved": True},
)
monkeypatch.setattr(process_registry_mod, "process_registry", registry)

View file

@ -1097,12 +1097,27 @@ def _normalize_approval_mode(mode) -> str:
YAML 1.1 treats bare words like `off` as booleans, so a config entry like
`approvals:\n mode: off` is parsed as False unless quoted. Treat that as the
intended string mode instead of falling back to manual approvals.
Unknown string values (e.g. 'auto') are rejected with a warning rather than
being silently accepted and falling through every mode check downstream.
Always returns one of 'manual', 'smart', or 'off'.
"""
_VALID_MODES = ("manual", "smart", "off")
if isinstance(mode, bool):
return "off" if mode is False else "manual"
if isinstance(mode, str):
normalized = mode.strip().lower()
return normalized or "manual"
if not normalized:
return "manual"
if normalized in _VALID_MODES:
return normalized
logger.warning(
"Unknown approvals.mode %r — defaulting to 'manual'. "
"Valid values: %s",
mode,
", ".join(_VALID_MODES),
)
return "manual"
return "manual"
@ -1268,8 +1283,23 @@ def _smart_approve(command: str, description: str) -> str:
return "escalate"
def _should_skip_container_guards(env_type: str, has_host_access: bool = False) -> bool:
"""Return True when the backend is isolated enough to skip dangerous-command prompts.
Isolated container backends sandbox the agent away from the host, so their
commands can't damage real files/services and we skip the approval layer.
Docker is the exception once host paths are bind-mounted into the container:
at that point a command like ``rm -rf /workspace`` reaches host files, so it
must go through the normal approval flow.
"""
if env_type == "docker":
return not has_host_access
return env_type in ("singularity", "modal", "daytona")
def check_dangerous_command(command: str, env_type: str,
approval_callback=None) -> dict:
approval_callback=None,
has_host_access: bool = False) -> dict:
"""Check if a command is dangerous and handle approval.
This is the main entry point called by terminal_tool before executing
@ -1279,11 +1309,13 @@ def check_dangerous_command(command: str, env_type: str,
command: The shell command to check.
env_type: Terminal backend type ('local', 'ssh', 'docker', etc.).
approval_callback: Optional CLI callback for interactive prompts.
has_host_access: True when a Docker sandbox bind-mounts host paths,
so its commands can reach the host and must not skip approval.
Returns:
{"approved": True/False, "message": str or None, ...}
"""
if env_type in {"docker", "singularity", "modal", "daytona"}:
if _should_skip_container_guards(env_type, has_host_access=has_host_access):
return {"approved": True, "message": None}
# Hardline floor: commands with no recovery path (rm -rf /, mkfs, dd
@ -1525,16 +1557,22 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict,
def check_all_command_guards(command: str, env_type: str,
approval_callback=None) -> dict:
approval_callback=None,
has_host_access: bool = False) -> dict:
"""Run all pre-exec security checks and return a single approval decision.
Gathers findings from tirith and dangerous-command detection, then
presents them as a single combined approval request. This prevents
a gateway force=True replay from bypassing one check when only the
other was shown to the user.
``has_host_access`` is True when a Docker sandbox bind-mounts host paths;
such a session is no longer isolated, so it goes through the normal flow
instead of the container fast-path.
"""
# Skip containers for both checks
if env_type in {"docker", "singularity", "modal", "daytona"}:
# Skip isolated container backends for both checks. Docker stops skipping
# once host paths are bind-mounted into the sandbox.
if _should_skip_container_guards(env_type, has_host_access=has_host_access):
return {"approved": True, "message": None}
# Hardline floor: unconditional block for catastrophic commands
@ -1857,7 +1895,8 @@ def check_all_command_guards(command: str, env_type: str,
"user_approved": True, "description": combined_desc}
def check_execute_code_guard(code: str, env_type: str) -> dict:
def check_execute_code_guard(code: str, env_type: str,
has_host_access: bool = False) -> dict:
"""Approve an execute_code script before its child process is spawned.
execute_code runs arbitrary local Python the script can call
@ -1883,8 +1922,12 @@ def check_execute_code_guard(code: str, env_type: str) -> dict:
)
# Isolated backends already sandbox the child — matches the container skip
# in check_all_command_guards / check_dangerous_command.
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
# in check_all_command_guards / check_dangerous_command. Docker stops
# skipping once host paths are bind-mounted into the sandbox; vercel_sandbox
# has no host-bind concept so it stays always-skipped.
if env_type == "vercel_sandbox":
return {"approved": True, "message": None}
if _should_skip_container_guards(env_type, has_host_access=has_host_access):
return {"approved": True, "message": None}
# --yolo or approvals.mode=off: bypass (session- or process-scoped).

View file

@ -1104,15 +1104,21 @@ def execute_code(
return tool_error("No code provided.")
# Dispatch: remote backends use file-based RPC, local uses UDS
from tools.terminal_tool import _get_env_config
env_type = _get_env_config()["env_type"]
from tools.terminal_tool import _get_env_config, _docker_has_host_access
_env_config = _get_env_config()
env_type = _env_config["env_type"]
# execute_code runs arbitrary Python (subprocess/os.system/...) that never
# passes through terminal()/DANGEROUS_PATTERNS, so guard the whole script
# here before either dispatch path spawns it. Runs synchronously in the
# caller (tool-executor) thread, which holds the session context (#30882).
# A Docker sandbox with host bind mounts is no longer isolated, so its
# script does not get the container fast-path.
from tools.approval import check_execute_code_guard
_guard = check_execute_code_guard(code, env_type)
_guard = check_execute_code_guard(
code, env_type,
has_host_access=_docker_has_host_access(_env_config),
)
if not _guard.get("approved", False):
return json.dumps({
"status": "error",

View file

@ -322,19 +322,28 @@ def find_docker() -> Optional[str]:
# preserved. Omitted entirely when the container starts as a
# non-root user via --user, since no privilege drop is needed
# in that mode.
# Block privilege escalation and limit PIDs.
# Block privilege escalation.
# /tmp is size-limited and nosuid but allows exec (needed by pip/npm builds).
#
# Note: ``--pids-limit`` is *not* in this list — it lives in ``resource_args``
# and is gated on ``_cgroup_limits_available(image)`` because it requires the
# ``pids`` cgroup controller to be delegated, which is not the case on hosts
# such as unprivileged LXCs. ``--cpus``/``--memory`` are gated for the same
# reason.
_BASE_SECURITY_ARGS = [
"--cap-drop", "ALL",
"--cap-add", "DAC_OVERRIDE",
"--cap-add", "CHOWN",
"--cap-add", "FOWNER",
"--security-opt", "no-new-privileges",
"--pids-limit", "256",
"--tmpfs", "/tmp:rw,nosuid,size=512m",
"--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m",
]
# Default per-container PID limit. Applied as ``--pids-limit`` only when the
# cgroup ``pids`` controller is available (see ``_cgroup_limits_available``).
_DEFAULT_PIDS_LIMIT = "256"
# /run is split out from _BASE_SECURITY_ARGS because s6-overlay images need it
# mounted ``exec``: s6 stage0 later runs ``exec /run/s6/basedir/bin/init``, which
# fails with "Permission denied" (exit 126) on a ``noexec`` mount. For all other
@ -431,6 +440,59 @@ def _resolve_host_user_spec() -> Optional[str]:
_storage_opt_ok: Optional[bool] = None # cached result across instances
_cgroup_limits_ok: Optional[bool] = None # cached result across instances
def _cgroup_limits_available(image: str) -> bool:
"""Probe whether cgroup resource limits work in this environment.
Tests ``--cpus``, ``--memory`` and ``--pids-limit`` together by spawning
a throwaway container from *image* (the same sandbox image we are about
to use for real, so no extra pull and no dependency on a public
registry). The container runs ``sleep 0`` sleep is guaranteed to be
present because the sandbox itself uses ``sleep 2h`` as its long-lived
entrypoint.
On hosts where the corresponding cgroup controllers are not delegated
to this process (typical inside unprivileged LXCs and some rootless
setups) these flags cause every container start to fail with ``OCI
runtime error`` / exit 126. The probe runs once per process and the
result which is host-wide, not image-specific is cached.
"""
global _cgroup_limits_ok
if _cgroup_limits_ok is not None:
return _cgroup_limits_ok
docker_exe = find_docker()
if not docker_exe or not image:
_cgroup_limits_ok = False
return False
try:
result = subprocess.run(
[docker_exe, "run", "--rm",
"--cpus", "0.5", "--memory", "64m", "--pids-limit", "32",
image, "sleep", "0"],
capture_output=True,
text=True,
timeout=60,
stdin=subprocess.DEVNULL,
)
_cgroup_limits_ok = result.returncode == 0
if not _cgroup_limits_ok:
logger.warning(
"Cgroup resource limits (--cpus/--memory/--pids-limit) not "
"available in this environment. Containers will run without "
"CPU, memory or PID limits. To enable, delegate the cpu, "
"memory and pids cgroup controllers to this container. "
"Probe stderr: %s",
(result.stderr or "").strip()[:500],
)
except Exception as e:
_cgroup_limits_ok = False
logger.warning("Cgroup limit probe failed; disabling resource limits: %s", e)
return _cgroup_limits_ok
def _ensure_docker_available() -> None:
@ -555,12 +617,17 @@ class DockerEnvironment(BaseEnvironment):
# Fail fast if Docker is not available.
_ensure_docker_available()
# Build resource limit args
# Build resource limit args (gated by cgroup availability probe so
# they degrade gracefully on hosts without controller delegation,
# e.g. unprivileged LXCs). The probe runs once per process and is
# cached host-wide.
resource_args = []
if cpu > 0:
if cpu > 0 and _cgroup_limits_available(image):
resource_args.extend(["--cpus", str(cpu)])
if memory > 0:
if memory > 0 and _cgroup_limits_available(image):
resource_args.extend(["--memory", f"{memory}m"])
if _cgroup_limits_available(image):
resource_args.extend(["--pids-limit", _DEFAULT_PIDS_LIMIT])
if disk > 0 and sys.platform != "darwin":
if self._storage_opt_supported():
resource_args.extend(["--storage-opt", f"size={disk}m"])

View file

@ -149,6 +149,8 @@ def load_env() -> Dict[str, str]:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
if line.startswith("export "):
line = line[7:]
key, _, value = line.partition("=")
env_vars[key.strip()] = value.strip().strip("\"'")
return env_vars

View file

@ -257,10 +257,33 @@ from tools.approval import (
)
def _check_all_guards(command: str, env_type: str) -> dict:
def _docker_volume_uses_host_path(volume_spec: str) -> bool:
"""Return True when a docker volume spec bind-mounts a host path."""
if not isinstance(volume_spec, str):
return False
vol = volume_spec.strip()
return bool(vol) and (
vol.startswith(("/", "~", "./", "../")) or
(len(vol) >= 3 and vol[1] == ":" and vol[2] in ("/", "\\"))
)
def _docker_has_host_access(config: Dict[str, Any]) -> bool:
"""Return True when a Docker sandbox exposes host paths through bind mounts."""
if config.get("env_type") != "docker":
return False
if config.get("host_cwd") and config.get("docker_mount_cwd_to_workspace"):
return True
return any(_docker_volume_uses_host_path(vol) for vol in config.get("docker_volumes", []))
def _check_all_guards(command: str, env_type: str,
has_host_access: bool = False) -> dict:
"""Delegate to consolidated guard (tirith + dangerous cmd) with CLI callback."""
return _check_all_guards_impl(command, env_type,
approval_callback=_get_approval_callback())
approval_callback=_get_approval_callback(),
has_host_access=has_host_access)
# Allowlist: characters that can legitimately appear in directory paths.
@ -2231,7 +2254,10 @@ def terminal_tool(
# Skip check if force=True (user has confirmed they want to run it)
approval_note = None
if not force:
approval = _check_all_guards(command, env_type)
approval = _check_all_guards(
command, env_type,
has_host_access=_docker_has_host_access(config),
)
if not approval["approved"]:
# Check if this is an approval_required (gateway ask mode)
if approval.get("status") == "pending_approval":