mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-07 13:02:07 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/desktop-multiterminal
This commit is contained in:
commit
ae465e9fb8
50 changed files with 1836 additions and 559 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
377
apps/desktop/src/app/overlays/panel.tsx
Normal file
377
apps/desktop/src/app/overlays/panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})` : ''
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => (
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}'")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
BIN
infographic/approval-mode-validation/infographic.png
Normal file
BIN
infographic/approval-mode-validation/infographic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
infographic/gateway-error-notify-logging/infographic.png
Normal file
BIN
infographic/gateway-error-notify-logging/infographic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
10
run_agent.py
10
run_agent.py
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
121
tests/hermes_cli/test_env_export_prefix.py
Normal file
121
tests/hermes_cli/test_env_export_prefix.py
Normal 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
|
||||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ===================================================================
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
91
tests/tools/test_docker_cgroup_limits.py
Normal file
91
tests/tools/test_docker_cgroup_limits.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue