mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
Merge branch 'main' into bb/salvage-43809-wsl-bridge
This commit is contained in:
commit
6d3009454d
70 changed files with 2883 additions and 227 deletions
|
|
@ -491,18 +491,18 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
|
|||
|
||||
### Electron Desktop Chat App (`apps/desktop/`)
|
||||
|
||||
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
|
||||
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.ts` + `backendSupportsServe()` in `electron/main.ts`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. For scoped Desktop architecture, state, resolver, transport, and testing rules, read `apps/desktop/AGENTS.md`.
|
||||
|
||||
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
|
||||
|
||||
- **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills.
|
||||
- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMANDS` (the ~19 built-ins shown in the palette) plus block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
|
||||
- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMAND_SPECS` (the built-ins and their Desktop surfaces) plus `NO_DESKTOP_SURFACE` block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
|
||||
- `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.
|
||||
- `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`.
|
||||
- `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.)
|
||||
- **Dispatch** lives in `app/session/hooks/use-prompt-actions.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
|
||||
- **Dispatch** lives in `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
|
||||
|
||||
**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: `apps/desktop/src/lib/desktop-slash-commands.test.ts` (run via the repo-root `vitest`, since `apps/desktop` resolves deps from the root workspace install).
|
||||
**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: from `apps/desktop`, run `npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace dependencies are installed at the repo root).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -412,13 +412,25 @@ def init_agent(
|
|||
agent.skip_context_files = skip_context_files
|
||||
agent.load_soul_identity = load_soul_identity
|
||||
agent.pass_session_id = pass_session_id
|
||||
agent._credential_pool = credential_pool
|
||||
agent.log_prefix_chars = log_prefix_chars
|
||||
agent.log_prefix = f"{log_prefix} " if log_prefix else ""
|
||||
# Store effective base URL for feature detection (prompt caching, reasoning, etc.)
|
||||
agent.base_url = base_url or ""
|
||||
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
|
||||
agent.provider = provider_name or ""
|
||||
if credential_pool is not None:
|
||||
try:
|
||||
from agent.credential_pool import credential_pool_matches_provider
|
||||
|
||||
if not credential_pool_matches_provider(
|
||||
credential_pool,
|
||||
agent.provider,
|
||||
base_url=agent.base_url,
|
||||
):
|
||||
credential_pool = None
|
||||
except Exception:
|
||||
credential_pool = None
|
||||
agent._credential_pool = credential_pool
|
||||
agent.acp_command = acp_command or command
|
||||
agent.acp_args = list(acp_args or args or [])
|
||||
if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", "codex_app_server"}:
|
||||
|
|
|
|||
|
|
@ -445,6 +445,44 @@ def get_pool_strategy(provider: str) -> str:
|
|||
return STRATEGY_FILL_FIRST
|
||||
|
||||
|
||||
def credential_pool_matches_provider(
|
||||
pool_or_provider: Any,
|
||||
provider: Optional[str],
|
||||
*,
|
||||
base_url: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Return whether a pool belongs to the requested runtime provider.
|
||||
|
||||
Named custom endpoints intentionally use two identities: the live agent is
|
||||
``custom`` while its pool is keyed ``custom:<name>``. Accept that pair only
|
||||
when the runtime base URL resolves to the exact same custom pool key.
|
||||
Empty string identities fail closed. Legacy pool adapters without a
|
||||
``provider`` attribute remain compatible; production pools are scoped.
|
||||
"""
|
||||
raw_pool_provider = getattr(pool_or_provider, "provider", None)
|
||||
if raw_pool_provider is None:
|
||||
if isinstance(pool_or_provider, str):
|
||||
raw_pool_provider = pool_or_provider
|
||||
else:
|
||||
# Backward compatibility for lightweight/unscoped pool adapters.
|
||||
# Production CredentialPool instances always carry ``provider``;
|
||||
# old plugins and tests may expose only select()/has_credentials().
|
||||
return True
|
||||
pool_provider = str(raw_pool_provider or "").strip().lower()
|
||||
provider_norm = str(provider or "").strip().lower()
|
||||
if not pool_provider or not provider_norm:
|
||||
return False
|
||||
if pool_provider == provider_norm:
|
||||
return True
|
||||
if provider_norm != "custom" or not pool_provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
return False
|
||||
try:
|
||||
matched_pool = get_custom_provider_pool_key(base_url or "")
|
||||
except Exception:
|
||||
return False
|
||||
return str(matched_pool or "").strip().lower() == pool_provider
|
||||
|
||||
|
||||
DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ def build_verify_on_stop_nudge(
|
|||
+ "), read any failure, repair the code, and summarize what passed."
|
||||
)
|
||||
else:
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_dir = os.path.realpath(tempfile.gettempdir())
|
||||
command_instruction = (
|
||||
"No canonical test/lint/build command was detected. Create a focused "
|
||||
f"temporary verification script under `{temp_dir}` using an OS-safe "
|
||||
|
|
|
|||
200
apps/desktop/AGENTS.md
Normal file
200
apps/desktop/AGENTS.md
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# Desktop Engineering Guide
|
||||
|
||||
How to build Hermes Desktop well. This is a judgment guide, not an inventory —
|
||||
it teaches the invariants and the reasoning behind them so a change fits the app
|
||||
even as files move. Read it with the repository `AGENTS.md` (root rules still
|
||||
apply) and [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract.
|
||||
|
||||
When a rule here and the code disagree, trust the code and fix whichever is
|
||||
wrong — but never break an invariant to make a change easier.
|
||||
|
||||
## What this app is
|
||||
|
||||
Desktop is its own native chat surface. It is not the browser dashboard and it
|
||||
does not embed the TUI. Three parties, each authoritative for one thing:
|
||||
|
||||
- **Electron** owns the machine: process lifecycle, native filesystem/git/
|
||||
windows, install/update, and a narrow, typed capability bridge.
|
||||
- **The renderer** owns the experience: navigation, presentation, and ephemeral
|
||||
interaction state.
|
||||
- **The agent backend** owns the work: sessions, tools, model calls, streaming.
|
||||
|
||||
Keep the seams clean. The renderer never reaches for Node or Electron directly;
|
||||
native power arrives through a deliberate capability, not a general escape hatch.
|
||||
Agent behavior lives behind the gateway, never reimplemented in React. When a
|
||||
change blurs a seam, that is the smell — fix the seam, don't widen it.
|
||||
|
||||
## Decide state by authority
|
||||
|
||||
The first question for any piece of state is *who is allowed to be right about
|
||||
it*, not where it is convenient to store it. Put state with its authority:
|
||||
|
||||
- The **backend** is authoritative for anything another Hermes surface can also
|
||||
change. Treat the renderer's copy as a cache of that truth.
|
||||
- **Electron** is authoritative for machine and runtime facts.
|
||||
- The **renderer** owns only what is purely about this window's presentation.
|
||||
|
||||
From that, everything else follows: shared renderer state lives in small stores
|
||||
owned by the feature that owns the concern; request-shaped server data that wants
|
||||
invalidation lives in the query layer; short-lived interaction detail stays in
|
||||
the component; hot coordination that must not paint stays in a ref. Reach for the
|
||||
narrowest home that still lets the state be correct. A new global store is a
|
||||
claim that many distant surfaces need it — earn that claim.
|
||||
|
||||
Persisted state must declare its scope in its own key: is this global, or does it
|
||||
belong to a connection, a profile, a stored session, a project, or a window?
|
||||
Getting the scope wrong is how one profile's setting bleeds into another.
|
||||
|
||||
## Identity is not incidental
|
||||
|
||||
Sessions have more than one identity, and conflating them is a recurring source
|
||||
of "session not found" and vanishing history. Reason about which identity a
|
||||
surface needs: durable navigation and anything the user pins or persists key off
|
||||
the stable/durable identity; live streaming keys off the runtime identity; state
|
||||
that must outlive compression keys off the lineage root. Keep the mapping between
|
||||
them explicit and translate at the boundary rather than passing the wrong id
|
||||
inward.
|
||||
|
||||
## Server truth is cached, not owned
|
||||
|
||||
The renderer paints from a cache of backend truth, so it must reconcile, not
|
||||
assume:
|
||||
|
||||
- **Merge, don't clobber.** A refresh is new information layered over what you
|
||||
already know, not a replacement that can drop live or pinned rows.
|
||||
- **Be optimistic, then honest.** Direct manipulation should paint immediately
|
||||
from a snapshot; a failed write rolls back visibly and an authoritative
|
||||
refresh gets the last word.
|
||||
- **Guard against the past.** Async results can arrive out of order; a stale
|
||||
response must never overwrite newer intent. Generation counters and request
|
||||
tokens exist for this.
|
||||
- **Isolate the foreground.** Only the surface the user is looking at may publish
|
||||
into the shared view; background work updates its own cache quietly.
|
||||
- **Coalesce noise, flush signal.** Batch high-frequency cosmetic updates, but
|
||||
let terminal transitions (a turn finishing, needing input, failing) reach the
|
||||
user immediately.
|
||||
- **Preserve reference identity on no-ops.** Handing React a fresh array that
|
||||
contains the same data re-renders expensive trees for nothing.
|
||||
|
||||
## Switching context is a re-home, not a reboot
|
||||
|
||||
Changing profile, connection, or mode is a workspace switch, not a cold start.
|
||||
The shell and whatever the user was doing stay put; only the gateway-bound view
|
||||
is cleared and repopulated, and the previous context must not leak into the next
|
||||
one. Reserve the full-screen boot/connecting experience for a genuinely unusable
|
||||
backend.
|
||||
|
||||
There are three distinct switch shapes, and conflating them is the classic bug:
|
||||
|
||||
- A **connection/mode apply** (local ↔ remote ↔ cloud) is the soft re-home:
|
||||
shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query
|
||||
invalidation alone cannot evict live session stores — wipe them.
|
||||
- A **runtime home change** (switching the underlying `HERMES_HOME` profile) is
|
||||
a hard re-home: the window legitimately reloads and state resets by remount.
|
||||
- A **live profile swap** in the same window activates another profile's socket
|
||||
while background profiles keep streaming; lists merge rather than wipe, and
|
||||
only an explicit user selection starts a fresh foreground draft.
|
||||
|
||||
Treating a soft switch as hard flickers the app; treating a hard one as soft
|
||||
strands stale rows. After any swap, the active socket, active profile, and
|
||||
connection atoms must agree, or REST and filesystem calls route to the wrong
|
||||
backend.
|
||||
|
||||
## Cross everything as an observable ladder
|
||||
|
||||
Desktop lives at the seams: versions, profiles, local vs remote vs cloud,
|
||||
partially installed runtimes, stale caches, older backends. The durable technique
|
||||
for all of it is the same — an ordered ladder of candidates:
|
||||
|
||||
1. Precedence is written down, in one place, as data or a pure function.
|
||||
2. A candidate is trusted only after it is validated at the right boundary.
|
||||
Existence is not proof; probe what you're about to rely on.
|
||||
3. A failed *read* falls to the next rung; a failed *authoritative write*
|
||||
surfaces or rolls back rather than silently retargeting.
|
||||
4. A missing capability and a transient failure are different: the first may
|
||||
enable a compatibility path or a disabled state; the second should retry.
|
||||
5. Retries are bounded and end in a real recovery affordance — never an infinite
|
||||
spinner or a hot loop.
|
||||
6. One resolver owns each policy so every caller gets the same answer. Scatter is
|
||||
how two call sites drift apart.
|
||||
|
||||
This is the shape of backend discovery, command/version fallbacks, connection and
|
||||
auth resolution, workspace-cwd selection, capability detection, and preview
|
||||
normalization alike. Learn the shape, not a snapshot of the current rungs.
|
||||
|
||||
Two auth-flavored corollaries worth naming because they are easy to get wrong:
|
||||
|
||||
- **One-time credentials are never reused.** An OAuth gateway connection mints a
|
||||
fresh WebSocket ticket on every dial; a mint failure means reauthentication,
|
||||
not "fall back to the cached URL." Only long-lived token/local auth may reuse
|
||||
a cached URL as a lower rung.
|
||||
- **A connection test must exercise the leg you'll actually use.** An HTTP
|
||||
status probe passing while the WebSocket/auth leg fails is a false positive
|
||||
that ships as "it said connected but nothing works."
|
||||
|
||||
## Compatibility without carrying the past forever
|
||||
|
||||
Desktop and its runtime update on separate clocks, so a change can meet an older
|
||||
backend. Keep those users working: preserve the current feature, keep the
|
||||
fallback narrow and tied to an identified older runtime, and cover it with a
|
||||
test. A fallback that quietly degrades the feature it's meant to protect is worse
|
||||
than the crash it replaced.
|
||||
|
||||
## Keep the waist narrow, grow at the edges
|
||||
|
||||
The root contribution rubric governs here too. New capability should arrive at
|
||||
the smallest surface that solves it: extend what exists, add a feature locally,
|
||||
lean on an existing seam — before you invent a framework. The shell's internal
|
||||
registries are composition seams, not a public plugin ABI; do not build a
|
||||
universal extension system, a manifest, or a plugin adapter for a single
|
||||
consumer. Design a shared contract only once more than one real consumer proves
|
||||
its shape. "Plugin" means several unrelated things across Hermes — do not assume
|
||||
one surface's extension model runs in another.
|
||||
|
||||
## Respect the person using it
|
||||
|
||||
Design and engineering meet at intent. The user's attention and context are
|
||||
sacred:
|
||||
|
||||
- Never navigate, move focus, or open a surface because something *happened* in
|
||||
the background. Offer; don't hijack.
|
||||
- The states around loading are distinct experiences — empty, loading,
|
||||
reconnecting, degraded/stale, and exhausted-recovery each deserve their own
|
||||
honest copy and their own way out.
|
||||
- Keyboard ownership follows focus. The focused surface wins its keys; one
|
||||
cancel gesture does exactly one thing.
|
||||
- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden.
|
||||
Visibility is not lifecycle.
|
||||
|
||||
## Make it feel instant
|
||||
|
||||
Performance is a feature the user feels, especially in drag, resize, scroll,
|
||||
typing, streaming, and terminals. The principles are timeless even as the code
|
||||
changes: keep hot-path state local or narrowly derived; don't subscribe heavy
|
||||
trees to per-frame updates; coalesce pointer work; avoid reading layout right
|
||||
after writing style; and don't mount expensive content mid-gesture. Prove speed
|
||||
against realistic content — a fast empty demo proves nothing about a long
|
||||
transcript. If motion is masking latency, remove the motion, don't tune it.
|
||||
|
||||
## Testing as a habit of proof
|
||||
|
||||
Test the behavior that would actually break a user, not a snapshot of today's
|
||||
data. Favor invariants over frozen values. Exercise the real path for anything
|
||||
at a seam — resolver precedence and its failure rungs, identity and scope
|
||||
boundaries, optimistic rollback and stale-response ordering, and both sides of a
|
||||
local/remote adapter with its profile routing intact. Match how the suite is
|
||||
actually run rather than inventing a command; when in doubt, read the scripts.
|
||||
|
||||
## The taste test before you hand off
|
||||
|
||||
- Does every piece of state live with its authority, at the narrowest scope?
|
||||
- Would a background event ever steal the foreground or the user's focus?
|
||||
- Does each resolver have one home, a validated ladder, and a bounded, recoverable
|
||||
end?
|
||||
- Do local, remote, and profile routing still agree?
|
||||
- Does async failure leave a usable UI and a way forward?
|
||||
- Do hot interactions stay cheap under realistic load?
|
||||
- Does the change pass the [`DESIGN.md`](./DESIGN.md) checklist and update all
|
||||
locales?
|
||||
|
||||
If any answer is "not sure," that's the part to go verify.
|
||||
|
|
@ -6,12 +6,29 @@ concern, tokens over literals, flat over boxed.** If you reach for a raw color,
|
|||
a one-off shadow, a bespoke button, or a hardcoded `px-*` on a control — stop,
|
||||
there's already a primitive for it.
|
||||
|
||||
This file owns the visual and interaction contract. Read
|
||||
[`AGENTS.md`](./AGENTS.md) for architecture, state, resolver, transport, and
|
||||
testing rules.
|
||||
|
||||
This doc contains two kinds of content, maintained differently:
|
||||
|
||||
- **Principles** (flatness, intent, feedback, motion, cancellation) are durable.
|
||||
They hold as components come and go.
|
||||
- **Named contracts** (tokens, `Button` variants, primitive names) are the
|
||||
design system's current API. They are maintained *with* the code: if you
|
||||
change a primitive, token, or variant, update its entry here **in the same
|
||||
change** — a stale name in this file is a bug, exactly like a stale type.
|
||||
|
||||
When a rule and the code disagree, fix whichever is wrong rather than forking a
|
||||
one-off at the call site.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Flat, not boxed.** No card-in-card, no divider borders inside a panel.
|
||||
Group with whitespace and a single hairline, never nested rounded boxes.
|
||||
2. **Borderless + shadow for elevation.** Overlays float on `shadow-nous` + a
|
||||
`--stroke-nous` hairline, not hard borders.
|
||||
2. **Borderless elevation for floating panels.** Overlays float on
|
||||
`shadow-nous` + a `--stroke-nous` hairline, not thick framed boxes. In-panel
|
||||
structure may use token hairlines sparingly.
|
||||
3. **One primitive per concern.** One `Button`, one set of control variants,
|
||||
one `SearchField`, one `Loader`, one `ErrorState`. Migrate onto them; don't
|
||||
fork.
|
||||
|
|
@ -20,11 +37,40 @@ there's already a primitive for it.
|
|||
5. **Style lives in the primitive.** Variants and sizes own padding, radius,
|
||||
color, chrome. Call sites pass a `variant`/`size`, not `className` overrides
|
||||
that re-specify those.
|
||||
6. **Intent before automation.** Surface useful actions and previews, but do not
|
||||
open panes, move focus, or navigate because a tool happened to produce
|
||||
something.
|
||||
7. **Immediate feedback.** Direct manipulation updates the view first. Network
|
||||
or disk persistence reconciles afterward and rolls back visibly on failure.
|
||||
|
||||
## Information architecture
|
||||
|
||||
- **Chat is the home surface.** The transcript and composer stay primary; tools,
|
||||
previews, files, review, and terminal complement the conversation.
|
||||
- **Pages are durable destinations.** Chat, Skills, Messaging, and Artifacts
|
||||
remain in shell chrome. Do not hide a distinct product noun inside an
|
||||
unrelated page.
|
||||
- **Route overlays are short tasks.** Settings, Command Center, Cron, Profiles,
|
||||
Agents, and Starmap render as `OverlayView` cards and return to the previous
|
||||
route on close. Model/session pickers and dialogs layer above the current
|
||||
surface; they are not navigation stacks.
|
||||
- **Panes are working context.** Preview, files, review, and terminal remain
|
||||
attached to the current task. Their state survives temporary hiding and chat
|
||||
switches where the underlying tool is meant to persist.
|
||||
- **One action, one home.** A command may have keyboard, palette, and visible
|
||||
affordances, but they invoke the same action and state. Do not fork behavior
|
||||
per entry point.
|
||||
- **Projects own workspace cwd.** Use Sidebar → Projects for local folders and
|
||||
worktrees; do not reintroduce a per-session/right-sidebar folder-picker flow.
|
||||
|
||||
Navigation must preserve context. A background session finishing, a tool result
|
||||
arriving, or a project refresh may update badges and cached data; it must not
|
||||
replace the foreground transcript or steal focus.
|
||||
|
||||
## Surfaces & elevation
|
||||
|
||||
Every overlay / dialog / toast (boot-failure, install, notifications,
|
||||
model-picker, onboarding, prompt-overlays, updates, base `Dialog`) uses:
|
||||
Floating panels (base `Dialog`, route overlays, boot/install/update surfaces,
|
||||
model-picker, onboarding, prompt overlays, notifications) use:
|
||||
|
||||
```
|
||||
shadow-nous /* downward-weighted, layered contact→ambient falloff */
|
||||
|
|
@ -35,6 +81,11 @@ Both are CSS vars in `src/styles.css` — tune in one place, everything inherits
|
|||
Don't add per-overlay `shadow-[…]` or `border-(--ui-stroke-secondary)`
|
||||
one-offs; if elevation needs to change, change the token.
|
||||
|
||||
Menus and popovers use their own shared `shadow-md` +
|
||||
`--ui-stroke-secondary` primitive treatment. Drag affordances may use tokenized
|
||||
dashed targets and local blur. These are semantic surface classes, not licenses
|
||||
for call-site shadow or border inventions.
|
||||
|
||||
## Stroke & color tokens
|
||||
|
||||
| Token | Use |
|
||||
|
|
@ -62,8 +113,9 @@ fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel",
|
|||
"Open logs").
|
||||
|
||||
**Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons
|
||||
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), and the icon
|
||||
family `icon` / `icon-xs` / `icon-sm` / `icon-lg` / `icon-titlebar`.
|
||||
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro`
|
||||
(status-stack/table-footers), and the icon family `icon` / `icon-xs` /
|
||||
`icon-sm` / `icon-lg` / `icon-titlebar`.
|
||||
|
||||
Notes:
|
||||
- Text buttons are square (no radius) and sized by padding + line-height (no
|
||||
|
|
@ -107,11 +159,36 @@ Notes:
|
|||
through for a11y.
|
||||
- **Logs:** `LogView` — no bg, hairline border, tight padding, small mono.
|
||||
Every place we surface raw logs uses it.
|
||||
- **Empty:** `EmptyState` / `EmptyPanel` — don't hand-roll centered empties.
|
||||
- **Empty:** `EmptyState` for plain page bodies; `PanelEmpty` for overlay
|
||||
master/detail empties with an icon and action. Don't hand-roll a third
|
||||
centered empty.
|
||||
|
||||
## Chat, tools & boot surfaces
|
||||
|
||||
- The transcript and composer are built on `@assistant-ui/react`. Extend the
|
||||
existing components under `src/components/assistant-ui` and
|
||||
`src/app/chat/composer`; do not fork a second markdown, message, tool-call, or
|
||||
approval renderer for one feature.
|
||||
- A tool result may expose an inline action that opens a preview. It must not
|
||||
open the rail automatically.
|
||||
- Install, onboarding, connecting, boot failure, and reauthentication are
|
||||
distinct states with shared visual primitives. Preserve their recovery
|
||||
semantics when unifying appearance.
|
||||
- Respect `AppShell` overlay ownership. Persistent terminal/content layers,
|
||||
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
|
||||
z-index literals.
|
||||
|
||||
## Iconography & brand
|
||||
|
||||
- **`Codicon`** is the icon set. No mixing icon libraries inline.
|
||||
- **Tabler** is the default component/chrome set. Import its curated aliases and
|
||||
`iconSize` scale from `src/lib/icons.ts`; do not import icon packages directly
|
||||
in feature code.
|
||||
- **`Codicon`** is the compact editor/tool/status vocabulary. Use
|
||||
`src/components/ui/codicon.tsx`, including `codiconIcon()` where a
|
||||
Tabler-shaped component is required.
|
||||
- Pick the vocabulary by semantic context and reuse the existing icon for an
|
||||
action. Do not introduce a third icon set or mix styles within one control
|
||||
group.
|
||||
- **`BrandMark`** (`src/components/brand-mark.tsx`) is the brand glyph — the
|
||||
`nous-girl` mark on a white tile, softly rounded, identical in light/dark.
|
||||
It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it
|
||||
|
|
@ -124,6 +201,43 @@ Notes:
|
|||
- Choreographed exits (e.g. onboarding's "matrix" fade-down) stagger per-element
|
||||
then settle the surface — the outer container's fade is *delayed* so it
|
||||
doesn't swallow the inner animation. Don't let a global fade race the detail.
|
||||
- Motion follows state; it never delays state. Selection, drag targets, cancel,
|
||||
and pressed feedback paint in the current frame.
|
||||
- Do not animate layout geometry with `transition-all` on a hot interaction.
|
||||
Name the properties, avoid backdrop-filter repaints during movement, and
|
||||
remove animation before masking a performance problem.
|
||||
|
||||
## Direct manipulation & performance
|
||||
|
||||
The app should feel instant under real load — long transcripts, several panes,
|
||||
live streams. Design toward that:
|
||||
|
||||
- Direct manipulation paints first; persistence reconciles after and rolls back
|
||||
visibly on failure.
|
||||
- Keep interaction feedback cheap: hot-path state stays local or narrowly
|
||||
derived, not wired into heavy trees; pointer work coalesces per frame.
|
||||
- One drop region has one visual owner, and drop targets speak one affordance
|
||||
language across files, sessions, tabs, and panes. Overlapping targets resolve
|
||||
to the active one instead of stacking overlays.
|
||||
- Forgiving geometry beats pixel-perfect triggers; edge actions live near their
|
||||
edge, not clustered in the center.
|
||||
- Expensive stateful surfaces stay mounted when hidden. Visibility is not
|
||||
lifecycle.
|
||||
|
||||
Prove speed with realistic content. A fast empty-state demo says nothing about a
|
||||
long transcript or a busy terminal.
|
||||
|
||||
## Keyboard & cancellation
|
||||
|
||||
- Keyboard ownership follows focus. The focused surface wins its keys; shell
|
||||
shortcuts must not steal a terminal's or editor's bindings.
|
||||
- Register global shortcuts through the shared layer, not ad-hoc listeners.
|
||||
- One cancel gesture does one thing: cancel the active interaction, or close the
|
||||
topmost dismissable surface — never both, never the control underneath.
|
||||
- Cancellation is synchronous in the UI even if cleanup is async: overlays,
|
||||
cursors, and pending gesture state clear at once.
|
||||
- Flows that deliberately cannot be dismissed (install/onboarding, destructive
|
||||
confirmation) must make that explicit.
|
||||
|
||||
## i18n
|
||||
|
||||
|
|
@ -135,12 +249,15 @@ Notes:
|
|||
|
||||
## State (TypeScript)
|
||||
|
||||
Mirrors the repo TS style (see root `AGENTS.md`):
|
||||
The detailed state contract lives in the scoped
|
||||
[`AGENTS.md`](./AGENTS.md). Visual code follows these essentials:
|
||||
|
||||
- Shared/cross-component state → small **nanostores**, not prop-drilling.
|
||||
Each feature owns its atoms; shared atoms live in `src/store`.
|
||||
- Rendering components subscribe with `useStore`; non-render actions read with
|
||||
`$atom.get()`.
|
||||
- Subscribe to derived coarse facts instead of high-frequency source atoms when
|
||||
the component does not render the full value.
|
||||
- Colocated action modules over god hooks. A hook owns one narrow job.
|
||||
- Keep persistence beside the atom that owns it. Route roots stay thin.
|
||||
- Prefer `interface` for public props; extend React primitives
|
||||
|
|
@ -163,5 +280,13 @@ Mirrors the repo TS style (see root `AGENTS.md`):
|
|||
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
|
||||
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
|
||||
- [ ] Flat — no card-in-card, no gratuitous row dividers?
|
||||
- [ ] No automatic navigation, focus steal, or pane opening from background
|
||||
events?
|
||||
- [ ] Direct manipulation paints immediately and rolls back cleanly on failure?
|
||||
- [ ] Hot interactions avoid broad subscriptions, layout thrash, and
|
||||
`transition-all`?
|
||||
- [ ] Keyboard ownership and single-action `Esc` behavior are correct?
|
||||
- [ ] All four locales updated for any new/changed string?
|
||||
- [ ] `cursor-pointer`, focus ring, and `Esc`-to-close behave?
|
||||
- [ ] Touched a primitive, token, or variant? Its named-contract entry in this
|
||||
file is updated in the same change.
|
||||
|
|
|
|||
|
|
@ -87,7 +87,63 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
|
|||
|
||||
### How it works
|
||||
|
||||
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.ts` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.ts`.
|
||||
The packaged app ships the Electron shell and a native React chat surface. On
|
||||
first launch it can install the Hermes Agent runtime into `HERMES_HOME`
|
||||
(`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows), using the same layout as a
|
||||
CLI install.
|
||||
|
||||
The app has three boundaries:
|
||||
|
||||
- **Electron** resolves and validates a runnable backend, owns native
|
||||
filesystem/git/window capabilities, and exposes a narrow preload bridge.
|
||||
- **React** owns the Desktop routes, panes, interaction state, and
|
||||
`@assistant-ui/react` transcript.
|
||||
- **Hermes Agent** runs as a headless `hermes serve` process and exposes the
|
||||
`tui_gateway` JSON-RPC/WebSocket API. The renderer connects through
|
||||
[`apps/shared`](../shared/), which is also used by the browser dashboard.
|
||||
|
||||
Backend resolution is an ordered ladder:
|
||||
|
||||
1. `HERMES_DESKTOP_HERMES_ROOT`
|
||||
2. the current source checkout during development
|
||||
3. a completed managed install
|
||||
4. `HERMES_DESKTOP_HERMES`, or `hermes` on `PATH`
|
||||
5. a system Python that can import the Hermes runtime
|
||||
6. the first-launch bootstrap installer
|
||||
|
||||
Candidates are probed before use; an existing shim or interpreter is not enough.
|
||||
A runtime that predates `serve` falls back to headless
|
||||
`dashboard --no-open`. This is compatibility for the backend command only and
|
||||
does not launch or embed the dashboard UI.
|
||||
|
||||
The Electron orchestration entry point is `electron/main.ts`; pure resolution,
|
||||
probe, hardening, and platform policies live in focused modules beside it. The
|
||||
renderer is under `src/`, with shared atoms in `src/store` and transport/native
|
||||
adapters in `src/lib`.
|
||||
|
||||
Before changing the app, read:
|
||||
|
||||
- [`AGENTS.md`](./AGENTS.md): architecture, state ownership, resolver/fallback,
|
||||
transport, performance, and testing rules.
|
||||
- [`DESIGN.md`](./DESIGN.md): visual system, information architecture, motion,
|
||||
direct manipulation, and keyboard behavior.
|
||||
|
||||
### Connections, projects, and switching
|
||||
|
||||
Desktop supports a managed local backend, explicit remote gateways, and Hermes
|
||||
Cloud connections. Remote and cloud modes use the same remote-capability path;
|
||||
authentication and discovery differ, not the renderer feature model.
|
||||
|
||||
Projects are the workspace abstraction. A project may own multiple folders,
|
||||
repositories, worktrees, and sessions; a bare new chat remains detached unless
|
||||
the user enters a project or configures a default project directory. Use the
|
||||
Projects UI rather than adding a second per-session folder-picker workflow.
|
||||
|
||||
Changing profiles or connection modes is a soft workspace switch, not another
|
||||
cold boot. The shell and current management overlay remain mounted while
|
||||
gateway-bound nanostores are wiped, query-backed data is invalidated, and the
|
||||
new connection repopulates skeletons. This prevents rows or transcripts from
|
||||
the previous gateway bleeding into the next one.
|
||||
|
||||
### Verification
|
||||
|
||||
|
|
@ -97,9 +153,13 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl
|
|||
npm run fix
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run test:desktop:all
|
||||
npm run test:ui
|
||||
npm run test:desktop:platforms
|
||||
```
|
||||
|
||||
Run `npm run test:desktop:all` for install, boot, update, packaging, or other
|
||||
release-path changes.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
|
|||
import { PanelEmpty } from '../overlays/panel'
|
||||
|
||||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
||||
import { FallbackModelsField } from './fallback-models-field'
|
||||
import { fieldCopyForSchemaKey } from './field-copy'
|
||||
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
|
||||
import { MemoryConnect } from './memory/connect'
|
||||
|
|
@ -100,6 +101,13 @@ function ConfigField({
|
|||
<ListRow action={action} description={descriptionNode} title={label} wide={wide} />
|
||||
)
|
||||
|
||||
// `fallback_providers` is a list of {provider, model} objects; the generic
|
||||
// `list` branch below would stringify them to "[object Object]". Render the
|
||||
// dedicated structured editor instead.
|
||||
if (schemaKey === 'fallback_providers') {
|
||||
return row(<FallbackModelsField onChange={onChange} value={value} />, true)
|
||||
}
|
||||
|
||||
if (schema.type === 'boolean') {
|
||||
return row(
|
||||
<div className="flex items-center justify-end">
|
||||
|
|
|
|||
113
apps/desktop/src/app/settings/fallback-models-field.test.tsx
Normal file
113
apps/desktop/src/app/settings/fallback-models-field.test.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Radix Select calls scrollIntoView / pointer-capture APIs jsdom lacks.
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
})
|
||||
|
||||
const getGlobalModelOptions = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelOptions: () => getGlobalModelOptions()
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
getGlobalModelOptions.mockResolvedValue({
|
||||
providers: [
|
||||
{ name: 'GitHub Copilot', slug: 'copilot', models: ['gpt-5-mini', 'gpt-5.4-mini'] },
|
||||
{ name: 'OpenAI Codex', slug: 'openai-codex', models: ['gpt-5.4-mini'] },
|
||||
{ name: 'Nous', slug: 'nous', models: ['hermes-4'] }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderField(value: unknown, onChange = vi.fn()) {
|
||||
const { FallbackModelsField } = await import('./fallback-models-field')
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<FallbackModelsField onChange={onChange} value={value} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
return onChange
|
||||
}
|
||||
|
||||
async function renderFieldWithRerender(value: unknown, onChange = vi.fn()) {
|
||||
const { FallbackModelsField } = await import('./fallback-models-field')
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<FallbackModelsField onChange={onChange} value={value} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
return (next: unknown) =>
|
||||
view.rerender(
|
||||
<QueryClientProvider client={client}>
|
||||
<FallbackModelsField onChange={onChange} value={next} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const CHAIN = [
|
||||
{ provider: 'copilot', model: 'gpt-5-mini' },
|
||||
{ provider: 'openai-codex', model: 'gpt-5.4-mini' }
|
||||
]
|
||||
|
||||
describe('FallbackModelsField', () => {
|
||||
it('renders each {provider, model} entry as its own row (never "[object Object]")', async () => {
|
||||
await renderField(CHAIN)
|
||||
|
||||
// One Remove control per entry proves the object list became rows — the old
|
||||
// generic `list` input stringified the array to "[object Object]".
|
||||
expect(screen.getAllByLabelText('Remove')).toHaveLength(2)
|
||||
expect(screen.getByText('Add fallback')).toBeTruthy()
|
||||
expect(screen.queryByText(/\[object Object\]/)).toBeNull()
|
||||
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('removing a row emits the remaining entries', async () => {
|
||||
const onChange = await renderField(CHAIN)
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('Remove')[0])
|
||||
|
||||
expect(onChange.mock.calls.at(-1)?.[0]).toEqual([{ provider: 'openai-codex', model: 'gpt-5.4-mini' }])
|
||||
})
|
||||
|
||||
it('adding a blank row does not persist a partial entry', async () => {
|
||||
const onChange = await renderField(CHAIN)
|
||||
|
||||
fireEvent.click(screen.getByText('Add fallback'))
|
||||
|
||||
// The new empty row stays in the UI but only complete pairs are emitted.
|
||||
expect(onChange.mock.calls.at(-1)?.[0]).toEqual(CHAIN)
|
||||
expect(screen.getAllByLabelText('Remove')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('shows an empty-state hint when there are no fallbacks', async () => {
|
||||
await renderField([])
|
||||
|
||||
expect(screen.getByText(/No fallback models/)).toBeTruthy()
|
||||
expect(screen.queryAllByLabelText('Remove')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('resyncs rows when persisted config changes', async () => {
|
||||
const rerender = await renderFieldWithRerender(CHAIN)
|
||||
expect(screen.getAllByLabelText('Remove')).toHaveLength(2)
|
||||
|
||||
rerender([{ provider: 'nous', model: 'hermes-4' }])
|
||||
|
||||
await waitFor(() => expect(screen.getAllByLabelText('Remove')).toHaveLength(1))
|
||||
})
|
||||
})
|
||||
143
apps/desktop/src/app/settings/fallback-models-field.tsx
Normal file
143
apps/desktop/src/app/settings/fallback-models-field.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Plus, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
|
||||
interface FallbackEntry {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
// Normalize the raw config value (`fallback_providers`: a list of
|
||||
// `{provider, model}` dicts) into editor rows. Defensive against legacy string
|
||||
// entries ("provider/model") so the editor never crashes on odd data.
|
||||
function normalizeEntries(value: unknown): FallbackEntry[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value.map(item => {
|
||||
if (item && typeof item === 'object') {
|
||||
const record = item as Record<string, unknown>
|
||||
|
||||
return { provider: String(record.provider ?? ''), model: String(record.model ?? '') }
|
||||
}
|
||||
|
||||
if (typeof item === 'string') {
|
||||
const slash = item.indexOf('/')
|
||||
|
||||
return slash > 0 ? { provider: item.slice(0, slash), model: item.slice(slash + 1) } : { provider: '', model: item }
|
||||
}
|
||||
|
||||
return { provider: '', model: '' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured editor for the top-level `fallback_providers` config list — a
|
||||
* chain of `{provider, model}` pairs tried in order when the default model
|
||||
* fails. Replaces the generic comma-string `list` input, which stringified the
|
||||
* objects to "[object Object], [object Object]".
|
||||
*
|
||||
* Mirrors the Auxiliary Models picker in `model-settings.tsx`: provider + model
|
||||
* selects sourced from `getGlobalModelOptions()`. Half-filled rows are kept in
|
||||
* local state and only complete pairs are emitted upward, so the config
|
||||
* autosave never persists a partial `{provider, model: ''}`.
|
||||
*/
|
||||
export function FallbackModelsField({
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
value: unknown
|
||||
onChange: (next: FallbackEntry[]) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const m = t.settings.model
|
||||
|
||||
const modelOptions = useQuery({
|
||||
queryKey: ['model-options', 'global'],
|
||||
queryFn: () => getGlobalModelOptions()
|
||||
})
|
||||
|
||||
const providers = (modelOptions.data?.providers ?? []).filter(provider => provider.slug)
|
||||
|
||||
const [rows, setRows] = useState<FallbackEntry[]>(() => normalizeEntries(value))
|
||||
|
||||
// Settings can reload after a profile/config change while this component
|
||||
// stays mounted. Avoid displaying or saving the previous profile's chain.
|
||||
useEffect(() => {
|
||||
setRows(normalizeEntries(value))
|
||||
}, [value])
|
||||
|
||||
const commit = (next: FallbackEntry[]) => {
|
||||
setRows(next)
|
||||
onChange(next.filter(entry => entry.provider && entry.model))
|
||||
}
|
||||
|
||||
const updateRow = (index: number, patch: Partial<FallbackEntry>) =>
|
||||
commit(rows.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)))
|
||||
|
||||
return (
|
||||
<div className="grid w-full gap-1.5">
|
||||
{rows.length === 0 && <p className="text-xs text-muted-foreground">{m.fallbackEmpty}</p>}
|
||||
{rows.map((entry, index) => {
|
||||
const providerRow = providers.find(provider => provider.slug === entry.provider)
|
||||
const catalog = providerRow?.models ?? []
|
||||
// Keep an out-of-catalog model selectable so an existing custom
|
||||
// provider/model renders instead of showing a blank box.
|
||||
const modelItems = entry.model && !catalog.includes(entry.model) ? [entry.model, ...catalog] : catalog
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2" key={index}>
|
||||
<span className="w-4 shrink-0 text-center font-mono text-[0.7rem] text-muted-foreground">{index + 1}</span>
|
||||
<Select onValueChange={provider => updateRow(index, { provider, model: '' })} value={entry.provider}>
|
||||
<SelectTrigger className={cn('min-w-36', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.provider} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map(provider => (
|
||||
<SelectItem key={provider.slug} value={provider.slug}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select onValueChange={model => updateRow(index, { model })} value={entry.model}>
|
||||
<SelectTrigger className={cn('min-w-52 flex-1', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.model} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelItems.map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
aria-label={t.common.remove}
|
||||
onClick={() => commit(rows.filter((_, i) => i !== index))}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={() => commit([...rows, { provider: '', model: '' }])} size="sm" variant="textStrong">
|
||||
<Plus className="size-3.5" />
|
||||
{m.fallbackAdd}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -298,23 +298,62 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
return moa.presets[selectedMoaPreset] || moa.presets[moa.default_preset] || Object.values(moa.presets)[0] || null
|
||||
}, [moa, selectedMoaPreset])
|
||||
|
||||
// Mirror of `moa` so inline edits compute the next state purely (outside the
|
||||
// setState updater) and hand it straight to the debounced autosave.
|
||||
const moaRef = useRef<MoaConfigResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
moaRef.current = moa
|
||||
}, [moa])
|
||||
|
||||
const moaSaveTimer = useRef<number | null>(null)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (moaSaveTimer.current) {
|
||||
window.clearTimeout(moaSaveTimer.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Quiet debounced persist for inline MoA edits — mirrors the config page's
|
||||
// autosave so slot/aggregator tweaks save themselves, matching the
|
||||
// preset-level ops (set default / add / delete) that already persist on
|
||||
// click. No `applying` spinner, so selecting stays responsive.
|
||||
const scheduleMoaSave = useCallback((next: MoaConfigResponse) => {
|
||||
if (moaSaveTimer.current) {
|
||||
window.clearTimeout(moaSaveTimer.current)
|
||||
}
|
||||
|
||||
moaSaveTimer.current = window.setTimeout(() => {
|
||||
void saveMoaModels(next)
|
||||
.then(setMoa)
|
||||
.catch(err => setError(err instanceof Error ? err.message : String(err)))
|
||||
}, 600)
|
||||
}, [])
|
||||
|
||||
const updateMoaPreset = useCallback(
|
||||
(updater: (preset: NonNullable<typeof currentMoaPreset>) => NonNullable<typeof currentMoaPreset>) => {
|
||||
setMoa(prev => {
|
||||
if (!prev || !selectedMoaPreset || !prev.presets[selectedMoaPreset]) {
|
||||
return prev
|
||||
}
|
||||
const prev = moaRef.current
|
||||
|
||||
return {
|
||||
...prev,
|
||||
presets: {
|
||||
...prev.presets,
|
||||
[selectedMoaPreset]: updater(prev.presets[selectedMoaPreset])
|
||||
}
|
||||
if (!prev || !selectedMoaPreset || !prev.presets[selectedMoaPreset]) {
|
||||
return
|
||||
}
|
||||
|
||||
const next: MoaConfigResponse = {
|
||||
...prev,
|
||||
presets: {
|
||||
...prev.presets,
|
||||
[selectedMoaPreset]: updater(prev.presets[selectedMoaPreset])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
moaRef.current = next
|
||||
setMoa(next)
|
||||
scheduleMoaSave(next)
|
||||
},
|
||||
[selectedMoaPreset]
|
||||
[scheduleMoaSave, selectedMoaPreset]
|
||||
)
|
||||
|
||||
const updateMoaSlot = useCallback((slot: MoaModelSlot, patch: Partial<MoaModelSlot>): MoaModelSlot => {
|
||||
|
|
@ -841,12 +880,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
</section>
|
||||
{moa && currentMoaPreset && (
|
||||
<section>
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<SectionHeading icon={Cpu} title="Mixture of Agents" />
|
||||
<Button disabled={applying} onClick={() => void saveMoa(moa)} size="sm" variant="textStrong">
|
||||
{applying ? m.applying : t.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
<SectionHeading icon={Cpu} title="Mixture of Agents" />
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Configure named presets that appear as models under the Mixture of Agents provider. The aggregator is the
|
||||
acting model.
|
||||
|
|
|
|||
112
apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx
Normal file
112
apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
|
||||
import { ClarifyTool, readClarifyResult } from './clarify-tool'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
function renderClarify(ui: ReactNode) {
|
||||
return render(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
{ui}
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function settledClarifyProps(
|
||||
args: ToolCallMessagePartProps['args'],
|
||||
result: ToolCallMessagePartProps['result'],
|
||||
toolCallId: string
|
||||
): ToolCallMessagePartProps {
|
||||
return {
|
||||
addResult: vi.fn(),
|
||||
args,
|
||||
argsText: JSON.stringify(args),
|
||||
isError: false,
|
||||
result,
|
||||
resume: vi.fn(),
|
||||
status: { type: 'complete' },
|
||||
toolCallId,
|
||||
toolName: 'clarify',
|
||||
type: 'tool-call'
|
||||
}
|
||||
}
|
||||
|
||||
describe('readClarifyResult', () => {
|
||||
it('reads question + user_response from the tool JSON payload', () => {
|
||||
expect(
|
||||
readClarifyResult({
|
||||
question: 'Which target?',
|
||||
choices_offered: ['staging', 'prod'],
|
||||
user_response: 'staging'
|
||||
})
|
||||
).toEqual({
|
||||
question: 'Which target?',
|
||||
answer: 'staging',
|
||||
error: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a JSON string result the same way as an object', () => {
|
||||
expect(
|
||||
readClarifyResult(
|
||||
JSON.stringify({
|
||||
question: 'Ship it?',
|
||||
user_response: 'yes'
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
question: 'Ship it?',
|
||||
answer: 'yes',
|
||||
error: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an empty user_response so Skip can render as skipped', () => {
|
||||
expect(readClarifyResult({ question: 'Ok?', user_response: '' })).toEqual({
|
||||
question: 'Ok?',
|
||||
answer: '',
|
||||
error: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClarifyTool settled view', () => {
|
||||
it('keeps the question and answer visible after the tool completes', () => {
|
||||
renderClarify(
|
||||
<ClarifyTool
|
||||
{...settledClarifyProps(
|
||||
{ question: 'Which deployment target?', choices: ['staging', 'prod'] },
|
||||
{
|
||||
question: 'Which deployment target?',
|
||||
choices_offered: ['staging', 'prod'],
|
||||
user_response: 'staging'
|
||||
},
|
||||
'clarify-1'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Which deployment target?')).toBeTruthy()
|
||||
expect(screen.getByText('staging')).toBeTruthy()
|
||||
expect(document.querySelector('[data-clarify-settled]')).toBeTruthy()
|
||||
expect(document.querySelector('[data-clarify-answer]')?.textContent).toBe('staging')
|
||||
})
|
||||
|
||||
it('labels an empty response as Skipped', () => {
|
||||
renderClarify(
|
||||
<ClarifyTool
|
||||
{...settledClarifyProps({ question: 'Anything else?' }, { question: 'Anything else?', user_response: '' }, 'clarify-2')}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Anything else?')).toBeTruthy()
|
||||
expect(screen.getByText('Skipped')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
@ -19,55 +19,74 @@ import { Kbd } from '@/components/ui/kbd'
|
|||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2, MessageQuestion } from '@/lib/icons'
|
||||
import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { selectMessageRunning } from './tool/fallback-model'
|
||||
import { parseMaybeObject } from './tool/fallback-model/format'
|
||||
|
||||
interface ClarifyArgs {
|
||||
question?: string
|
||||
choices?: string[] | null
|
||||
}
|
||||
|
||||
function readClarifyArgs(args: unknown): ClarifyArgs {
|
||||
if (!args || typeof args !== 'object') {
|
||||
return {}
|
||||
}
|
||||
interface ClarifyResult {
|
||||
question?: string
|
||||
answer?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const row = args as Record<string, unknown>
|
||||
function stringField(row: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = row[key]
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readClarifyArgs(args: unknown): ClarifyArgs {
|
||||
const row = parseMaybeObject(args)
|
||||
const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null
|
||||
|
||||
return {
|
||||
question: typeof row.question === 'string' ? row.question : undefined,
|
||||
question: stringField(row, 'question'),
|
||||
choices: choices && choices.length > 0 ? choices : null
|
||||
}
|
||||
}
|
||||
|
||||
// Each option (and "Other") is keyed A, B, C… so it can be picked by pressing
|
||||
// that letter — the badge doubles as the shortcut hint.
|
||||
/** Parse clarify tool JSON (`question` + `user_response`). */
|
||||
export function readClarifyResult(result: unknown): ClarifyResult {
|
||||
const row = parseMaybeObject(result)
|
||||
|
||||
if (Object.keys(row).length === 0) {
|
||||
return typeof result === 'string' && result.trim() ? { answer: result.trim() } : {}
|
||||
}
|
||||
|
||||
return {
|
||||
question: stringField(row, 'question'),
|
||||
answer: stringField(row, 'user_response', 'answer'),
|
||||
error: stringField(row, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const letterFor = (index: number): string => String.fromCharCode(65 + index)
|
||||
|
||||
// Choice and "Other" rows share a layout; only color differs. Mirrors a tool
|
||||
// row's compact rhythm so the panel reads as part of the transcript.
|
||||
const OPTION_ROW_CLASS =
|
||||
'flex w-full items-start gap-2 rounded-[0.25rem] px-1.5 py-1 text-left disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
// Content-sizing freeform field (CSS `field-sizing` — same primitive as the
|
||||
// commit bar and search field): starts at one line, grows with what's typed,
|
||||
// and never reflows the panel when focused. Bare so the "Other" row matches the
|
||||
// choice rows above it.
|
||||
const FREEFORM_INPUT_CLASS =
|
||||
'field-sizing-content max-h-40 min-h-0 w-full resize-none bg-transparent p-0 leading-(--conversation-line-height) text-(--ui-text-primary) outline-none placeholder:text-(--ui-text-tertiary) disabled:opacity-50'
|
||||
// field-sizing on top of Textarea's shared chrome; kill min-h-16 for one-liners.
|
||||
const CLARIFY_TEXTAREA_CLASS = 'field-sizing-content max-h-40 min-h-0 resize-none'
|
||||
|
||||
// Quiet inline panel that matches the surrounding tool rows: a single hairline
|
||||
// border in the shared stroke token, a soft surface fill, and a faint primary
|
||||
// accent that signals "this one needs you" without the loud animated ring.
|
||||
const CLARIFY_SHELL_CLASS =
|
||||
'my-1.5 rounded-md border border-primary/20 bg-(--ui-chat-surface-background) text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)'
|
||||
|
||||
const CLARIFY_ICON_CLASS = 'mt-px size-4 shrink-0 text-(--ui-text-tertiary)'
|
||||
|
||||
function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div className={cn(CLARIFY_SHELL_CLASS, className)} data-slot="clarify-inline" {...props}>
|
||||
|
|
@ -76,10 +95,20 @@ function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>)
|
|||
)
|
||||
}
|
||||
|
||||
// Selection lives on the letter badge alone — a solid primary fill — not the
|
||||
// whole row, which stays a quiet hover target. `preview` is the focused-but-empty
|
||||
// "Other" state: the badge outlines in primary to show it's armed, then fills
|
||||
// once a value is actually typed.
|
||||
function ClarifyLine({
|
||||
children,
|
||||
className,
|
||||
icon: Icon,
|
||||
...props
|
||||
}: ComponentProps<'div'> & { icon: typeof MessageQuestion }) {
|
||||
return (
|
||||
<div className={cn('flex items-start gap-2', className)} {...props}>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
<Icon aria-hidden className={CLARIFY_ICON_CLASS} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean; selected: boolean }) {
|
||||
return (
|
||||
<Kbd
|
||||
|
|
@ -96,21 +125,62 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean
|
|||
}
|
||||
|
||||
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
|
||||
// Answered → settled Q&A (ToolFallback collapsed the answer away).
|
||||
if (props.result !== undefined) {
|
||||
return <ClarifyToolSettled {...props} />
|
||||
}
|
||||
|
||||
return <ClarifyToolLive {...props} />
|
||||
}
|
||||
|
||||
function ClarifyToolLive(props: ToolCallMessagePartProps) {
|
||||
const messageRunning = useAuiState(selectMessageRunning)
|
||||
|
||||
// Only the live, still-blocked turn shows the interactive panel. Once the
|
||||
// message stops running — answered, the turn ended, or the user hit Stop —
|
||||
// fall back to the standard tool block so the Q/A settles like every other
|
||||
// row instead of stranding a dead prompt the gateway no longer waits on.
|
||||
const isPending = messageRunning && props.result === undefined
|
||||
|
||||
if (!isPending) {
|
||||
// Stopped mid-prompt with no result — don't leave a dead interactive panel.
|
||||
if (!messageRunning) {
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
return <ClarifyToolPending {...props} />
|
||||
}
|
||||
|
||||
function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.clarify
|
||||
const fromArgs = useMemo(() => readClarifyArgs(args), [args])
|
||||
const fromResult = useMemo(() => readClarifyResult(result), [result])
|
||||
|
||||
const question = fromResult.question || fromArgs.question || ''
|
||||
const answer = fromResult.answer
|
||||
const error = fromResult.error
|
||||
const skipped = !error && answer !== undefined && !answer.trim()
|
||||
const answerText = error || (skipped ? copy.skipped : (answer ?? '').trim())
|
||||
|
||||
return (
|
||||
<ClarifyShell className="grid gap-1.5 px-2.5 py-2" data-clarify-settled="">
|
||||
{question ? (
|
||||
<ClarifyLine icon={MessageQuestion}>
|
||||
<span className="whitespace-pre-wrap font-medium leading-(--conversation-line-height)">{question}</span>
|
||||
</ClarifyLine>
|
||||
) : null}
|
||||
{answerText ? (
|
||||
<ClarifyLine icon={CircleLetterA}>
|
||||
<p
|
||||
className={cn(
|
||||
'whitespace-pre-wrap leading-(--conversation-line-height)',
|
||||
error ? 'text-destructive' : 'text-(--ui-text-secondary)',
|
||||
skipped && 'italic text-(--ui-text-tertiary)'
|
||||
)}
|
||||
data-clarify-answer=""
|
||||
>
|
||||
{answerText}
|
||||
</p>
|
||||
</ClarifyLine>
|
||||
) : null}
|
||||
</ClarifyShell>
|
||||
)
|
||||
}
|
||||
|
||||
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.clarify
|
||||
|
|
@ -175,8 +245,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||
})
|
||||
triggerHaptic('submit')
|
||||
clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId)
|
||||
// The matching tool.complete will land shortly after, swapping this
|
||||
// panel for the ToolFallback view above.
|
||||
// tool.complete lands next → ClarifyToolSettled.
|
||||
} catch (error) {
|
||||
notifyError(error, copy.sendFailed)
|
||||
setSubmitting(false)
|
||||
|
|
@ -327,17 +396,13 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
</button>
|
||||
))}
|
||||
{/* "Other" is an inline content-sizing field, not a separate view. */}
|
||||
<label className={cn(OPTION_ROW_CLASS, 'focus-within:bg-(--chrome-action-hover)')}>
|
||||
<label className={cn(OPTION_ROW_CLASS, 'items-center')}>
|
||||
<KeyBadge char={letterFor(choices.length)} preview={otherFocused} selected={Boolean(trimmedDraft)} />
|
||||
<textarea
|
||||
className={FREEFORM_INPUT_CLASS}
|
||||
<Textarea
|
||||
className={CLARIFY_TEXTAREA_CLASS}
|
||||
disabled={submitting}
|
||||
onBlur={() => setOtherFocused(false)}
|
||||
onChange={event => onDraftChange(event.target.value)}
|
||||
// Focusing "Other" is a switch to typing your own answer, so it
|
||||
// deselects any picked choice — a chosen option and an active
|
||||
// Other field can never both look selected.
|
||||
onFocus={() => {
|
||||
setSelectedChoice(null)
|
||||
setOtherFocused(true)
|
||||
|
|
@ -346,19 +411,21 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||
placeholder={copy.other}
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
size="sm"
|
||||
value={draft}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
className={FREEFORM_INPUT_CLASS}
|
||||
className={CLARIFY_TEXTAREA_CLASS}
|
||||
disabled={submitting}
|
||||
onChange={event => onDraftChange(event.target.value)}
|
||||
onKeyDown={handleTextareaKey}
|
||||
placeholder={copy.placeholder}
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
size="sm"
|
||||
value={draft}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
67
apps/desktop/src/components/prompt-overlays.test.tsx
Normal file
67
apps/desktop/src/components/prompt-overlays.test.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $secretRequest, $sudoRequest, clearAllPrompts, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
|
||||
import { PromptOverlays } from './prompt-overlays'
|
||||
|
||||
vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))
|
||||
vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() }))
|
||||
|
||||
function renderPrompts() {
|
||||
render(
|
||||
<I18nProvider configClient={null}>
|
||||
<PromptOverlays />
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set(null)
|
||||
$gateway.set(null)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('PromptOverlays', () => {
|
||||
it('dismisses a stale sudo dialog when the gateway no longer has the password request', async () => {
|
||||
const request = vi.fn().mockRejectedValue(new Error('no pending password request'))
|
||||
|
||||
$activeSessionId.set('s1')
|
||||
$gateway.set({ request } as never)
|
||||
setSudoRequest({ requestId: 'sudo-1', sessionId: 's1' })
|
||||
|
||||
renderPrompts()
|
||||
|
||||
expect(screen.getByText('Administrator password')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
await waitFor(() => expect($sudoRequest.get()).toBeNull())
|
||||
expect(request).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' })
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dismisses a stale secret dialog when the gateway no longer has the value request', async () => {
|
||||
const request = vi.fn().mockRejectedValue(new Error('no pending value request'))
|
||||
|
||||
$activeSessionId.set('s1')
|
||||
$gateway.set({ request } as never)
|
||||
setSecretRequest({ envVar: 'TEST_SECRET', prompt: 'Paste a secret', requestId: 'secret-1', sessionId: 's1' })
|
||||
|
||||
renderPrompts()
|
||||
|
||||
expect(screen.getByText('TEST_SECRET')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
await waitFor(() => expect($secretRequest.get()).toBeNull())
|
||||
expect(request).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' })
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { isMissingPendingPromptRequest } from '@/lib/gateway-rpc'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { KeyRound, Loader2, Lock } from '@/lib/icons'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
|
|
@ -69,6 +70,12 @@ function SudoDialog() {
|
|||
triggerHaptic('submit')
|
||||
clearSudoRequest(request.sessionId, request.requestId)
|
||||
} catch (error) {
|
||||
if (isMissingPendingPromptRequest(error, 'password')) {
|
||||
clearSudoRequest(request.sessionId, request.requestId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(error, copy.sudoSendFailed)
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
|
@ -165,6 +172,12 @@ function SecretDialog() {
|
|||
triggerHaptic('submit')
|
||||
clearSecretRequest(request.sessionId, request.requestId)
|
||||
} catch (error) {
|
||||
if (isMissingPendingPromptRequest(error, 'value')) {
|
||||
clearSecretRequest(request.sessionId, request.requestId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(error, copy.secretSendFailed)
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -707,6 +707,9 @@ export const en: Translations = {
|
|||
change: 'Change',
|
||||
autoUseMain: 'auto · use main model',
|
||||
providerDefault: '(provider default)',
|
||||
fallbackAdd: 'Add fallback',
|
||||
fallbackEmpty: 'No fallback models — the default model is used unless it fails.',
|
||||
notInCatalog: "isn't in this provider's model list — calls may fall back to a backup.",
|
||||
tasks: {
|
||||
vision: { label: 'Vision', hint: 'Image analysis' },
|
||||
web_extract: { label: 'Web extract', hint: 'Page summarization' },
|
||||
|
|
@ -2324,6 +2327,7 @@ export const en: Translations = {
|
|||
other: 'Other (type your answer)',
|
||||
placeholder: 'Type your answer…',
|
||||
skip: 'Skip',
|
||||
skipped: 'Skipped',
|
||||
continueLabel: 'Continue'
|
||||
},
|
||||
tool: {
|
||||
|
|
|
|||
|
|
@ -2266,6 +2266,7 @@ export const ja = defineLocale({
|
|||
other: 'その他(回答を入力)',
|
||||
placeholder: '回答を入力…',
|
||||
skip: 'スキップ',
|
||||
skipped: 'スキップ済み',
|
||||
continueLabel: '続行'
|
||||
},
|
||||
tool: {
|
||||
|
|
|
|||
|
|
@ -611,6 +611,9 @@ export interface Translations {
|
|||
change: string
|
||||
autoUseMain: string
|
||||
providerDefault: string
|
||||
fallbackAdd: string
|
||||
fallbackEmpty: string
|
||||
notInCatalog: string
|
||||
tasks: Record<string, AuxTaskCopy>
|
||||
}
|
||||
providers: {
|
||||
|
|
@ -1947,6 +1950,7 @@ export interface Translations {
|
|||
other: string
|
||||
placeholder: string
|
||||
skip: string
|
||||
skipped: string
|
||||
continueLabel: string
|
||||
}
|
||||
tool: {
|
||||
|
|
|
|||
|
|
@ -2197,6 +2197,7 @@ export const zhHant = defineLocale({
|
|||
other: '其他(輸入您的答案)',
|
||||
placeholder: '輸入您的答案…',
|
||||
skip: '略過',
|
||||
skipped: '已略過',
|
||||
continueLabel: '繼續'
|
||||
},
|
||||
tool: {
|
||||
|
|
|
|||
|
|
@ -895,6 +895,9 @@ export const zh: Translations = {
|
|||
change: '更改',
|
||||
autoUseMain: '自动 · 使用主模型',
|
||||
providerDefault: '(提供方默认)',
|
||||
fallbackAdd: '添加备用模型',
|
||||
fallbackEmpty: '未配置备用模型 — 默认模型失败时才会使用备用模型。',
|
||||
notInCatalog: '不在该提供方的模型列表中 — 调用可能回退到备用模型。',
|
||||
tasks: {
|
||||
vision: { label: '视觉', hint: '图片分析' },
|
||||
web_extract: { label: '网页提取', hint: '页面总结' },
|
||||
|
|
@ -2483,6 +2486,7 @@ export const zh: Translations = {
|
|||
other: '其他 (输入你的答案)',
|
||||
placeholder: '输入你的答案…',
|
||||
skip: '跳过',
|
||||
skipped: '已跳过',
|
||||
continueLabel: '继续'
|
||||
},
|
||||
tool: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isMissingRpcMethod } from './gateway-rpc'
|
||||
import { isMissingPendingPromptRequest, isMissingRpcMethod } from './gateway-rpc'
|
||||
|
||||
describe('isMissingRpcMethod', () => {
|
||||
it('detects JSON-RPC method-not-found errors', () => {
|
||||
|
|
@ -14,3 +14,15 @@ describe('isMissingRpcMethod', () => {
|
|||
expect(isMissingRpcMethod(new Error('no such project'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMissingPendingPromptRequest', () => {
|
||||
it('detects stale prompt response errors from the gateway', () => {
|
||||
expect(isMissingPendingPromptRequest(new Error('no pending password request'), 'password')).toBe(true)
|
||||
expect(isMissingPendingPromptRequest(new Error('RPC failed: no pending value request'), 'value')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores unrelated gateway failures', () => {
|
||||
expect(isMissingPendingPromptRequest(new Error('gateway not connected'), 'password')).toBe(false)
|
||||
expect(isMissingPendingPromptRequest(new Error('no pending value request'), 'password')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,3 +4,10 @@ export function isMissingRpcMethod(error: unknown): boolean {
|
|||
|
||||
return /method not found|-32601|unknown method|no such method/i.test(message)
|
||||
}
|
||||
|
||||
/** True when a prompt response raced a backend-side timeout / completion. */
|
||||
export function isMissingPendingPromptRequest(error: unknown, key: string): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return message.toLowerCase().includes(`no pending ${key.toLowerCase()} request`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import {
|
|||
IconLogin as LogIn,
|
||||
IconMail as Mail,
|
||||
IconMaximize as Maximize,
|
||||
IconCircleLetterA as CircleLetterA,
|
||||
IconMessageCircle as MessageCircle,
|
||||
IconMessageQuestion as MessageQuestion,
|
||||
IconMessage2 as MessageSquareText,
|
||||
|
|
@ -141,6 +142,7 @@ export {
|
|||
ChevronRight,
|
||||
ChevronRightIcon,
|
||||
CircleIcon,
|
||||
CircleLetterA,
|
||||
Clipboard,
|
||||
Clock,
|
||||
Cloud,
|
||||
|
|
|
|||
|
|
@ -882,9 +882,13 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
self._run_streams: Dict[str, "asyncio.Queue[Optional[Dict]]"] = {}
|
||||
# Creation timestamps for orphaned-run TTL sweep
|
||||
self._run_streams_created: Dict[str, float] = {}
|
||||
# Runs with a connected SSE consumer; their queue is actively draining.
|
||||
self._run_stream_subscribers: set[str] = set()
|
||||
# Active run agent/task references for stop support
|
||||
self._active_run_agents: Dict[str, Any] = {}
|
||||
self._active_run_tasks: Dict[str, "asyncio.Task"] = {}
|
||||
# Stop is cooperative: the executor thread may outlive the HTTP request.
|
||||
self._stopping_run_ids: set[str] = set()
|
||||
# Pollable run status for dashboards and external control-plane UIs.
|
||||
self._run_statuses: Dict[str, Dict[str, Any]] = {}
|
||||
# Active approval session key for each run_id. The approval core
|
||||
|
|
@ -899,7 +903,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
# from a request flood (#7483).
|
||||
self._max_concurrent_runs: int = self._resolve_max_concurrent_runs()
|
||||
# Number of in-flight runs on the non-streaming chat/responses paths
|
||||
# (the /v1/runs path tracks its own in-flight set via _run_streams).
|
||||
# (the /v1/runs path tracks its own in-flight set via
|
||||
# _active_run_tasks).
|
||||
self._inflight_agent_runs: int = 0
|
||||
|
||||
def _readiness_work_counts(self) -> tuple[int, int, int]:
|
||||
|
|
@ -4001,14 +4006,18 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
|
||||
The cap bounds total in-flight agent activity across every
|
||||
agent-serving endpoint: the non-streaming chat/responses paths
|
||||
(tracked by ``_inflight_agent_runs``) plus the ``/v1/runs`` streaming
|
||||
path (tracked by ``_run_streams``). A configured value of 0 disables
|
||||
the cap entirely.
|
||||
(tracked by ``_inflight_agent_runs``) plus the ``/v1/runs`` path
|
||||
(tracked by live entries in ``_active_run_tasks``). Stream queues are
|
||||
transport state and may disappear while their underlying run remains
|
||||
active, so they must not define run concurrency. A configured value of
|
||||
0 disables the cap entirely.
|
||||
"""
|
||||
limit = self._max_concurrent_runs
|
||||
if limit <= 0:
|
||||
return None
|
||||
inflight = self._inflight_agent_runs + len(self._run_streams)
|
||||
inflight = self._inflight_agent_runs + sum(
|
||||
not task.done() for task in self._active_run_tasks.values()
|
||||
)
|
||||
if inflight >= limit:
|
||||
return web.json_response(
|
||||
_openai_error(
|
||||
|
|
@ -4295,12 +4304,19 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
|
||||
event_cb = self._make_run_event_callback(run_id, loop)
|
||||
|
||||
def _put_event_if_active(event: Optional[Dict]) -> None:
|
||||
"""Enqueue only while this run still owns live transport state."""
|
||||
if self._run_streams.get(run_id) is q:
|
||||
q.put_nowait(event)
|
||||
|
||||
# Also wire stream_delta_callback so message.delta events flow through.
|
||||
def _text_cb(delta: Optional[str]) -> None:
|
||||
if delta is None:
|
||||
return
|
||||
if run_id not in self._run_streams:
|
||||
return
|
||||
try:
|
||||
loop.call_soon_threadsafe(q.put_nowait, {
|
||||
loop.call_soon_threadsafe(_put_event_if_active, {
|
||||
"event": "message.delta",
|
||||
"run_id": run_id,
|
||||
"timestamp": time.time(),
|
||||
|
|
@ -4323,6 +4339,18 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
async def _run_and_close():
|
||||
try:
|
||||
self._set_run_status(run_id, "running")
|
||||
if run_id in self._stopping_run_ids:
|
||||
_put_event_if_active({
|
||||
"event": "run.cancelled",
|
||||
"run_id": run_id,
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
self._set_run_status(
|
||||
run_id,
|
||||
"cancelled",
|
||||
last_event="run.cancelled",
|
||||
)
|
||||
return
|
||||
agent = self._create_agent(
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
session_id=session_id,
|
||||
|
|
@ -4407,12 +4435,23 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
return r, u
|
||||
|
||||
result, usage = await asyncio.get_running_loop().run_in_executor(None, _run_sync)
|
||||
if run_id in self._stopping_run_ids:
|
||||
_put_event_if_active({
|
||||
"event": "run.cancelled",
|
||||
"run_id": run_id,
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
self._set_run_status(
|
||||
run_id,
|
||||
"cancelled",
|
||||
last_event="run.cancelled",
|
||||
)
|
||||
# Check for structured failure (non-retryable client errors like
|
||||
# 401/400 return failed=True instead of raising, so the except
|
||||
# block below never fires — issue #15561).
|
||||
if isinstance(result, dict) and result.get("failed"):
|
||||
elif isinstance(result, dict) and result.get("failed"):
|
||||
error_msg = _redact_api_error_text(result.get("error") or "agent run failed")
|
||||
q.put_nowait({
|
||||
_put_event_if_active({
|
||||
"event": "run.failed",
|
||||
"run_id": run_id,
|
||||
"timestamp": time.time(),
|
||||
|
|
@ -4426,7 +4465,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
)
|
||||
else:
|
||||
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
|
||||
q.put_nowait({
|
||||
_put_event_if_active({
|
||||
"event": "run.completed",
|
||||
"run_id": run_id,
|
||||
"timestamp": time.time(),
|
||||
|
|
@ -4447,7 +4486,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
last_event="run.cancelled",
|
||||
)
|
||||
try:
|
||||
q.put_nowait({
|
||||
_put_event_if_active({
|
||||
"event": "run.cancelled",
|
||||
"run_id": run_id,
|
||||
"timestamp": time.time(),
|
||||
|
|
@ -4464,7 +4503,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
last_event="run.failed",
|
||||
)
|
||||
try:
|
||||
q.put_nowait({
|
||||
_put_event_if_active({
|
||||
"event": "run.failed",
|
||||
"run_id": run_id,
|
||||
"timestamp": time.time(),
|
||||
|
|
@ -4486,12 +4525,13 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
pass
|
||||
# Sentinel: signal SSE stream to close
|
||||
try:
|
||||
q.put_nowait(None)
|
||||
_put_event_if_active(None)
|
||||
except Exception:
|
||||
pass
|
||||
self._active_run_agents.pop(run_id, None)
|
||||
self._active_run_tasks.pop(run_id, None)
|
||||
self._run_approval_sessions.pop(run_id, None)
|
||||
self._stopping_run_ids.discard(run_id)
|
||||
|
||||
task = asyncio.create_task(_run_and_close())
|
||||
self._active_run_tasks[run_id] = task
|
||||
|
|
@ -4543,6 +4583,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404)
|
||||
|
||||
q = self._run_streams[run_id]
|
||||
self._run_stream_subscribers.add(run_id)
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
|
|
@ -4570,6 +4611,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
except Exception as exc:
|
||||
logger.debug("[api_server] SSE stream error for run %s: %s", run_id, exc)
|
||||
finally:
|
||||
self._run_stream_subscribers.discard(run_id)
|
||||
self._run_streams.pop(run_id, None)
|
||||
self._run_streams_created.pop(run_id, None)
|
||||
|
||||
|
|
@ -4678,6 +4720,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404)
|
||||
|
||||
self._set_run_status(run_id, "stopping", last_event="run.stopping")
|
||||
self._stopping_run_ids.add(run_id)
|
||||
|
||||
if agent is not None:
|
||||
try:
|
||||
|
|
@ -4685,37 +4728,29 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
# Bounded wait: run_conversation() executes in the default
|
||||
# executor thread which task.cancel() cannot preempt — we rely on
|
||||
# agent.interrupt() above to break the loop. Cap the wait so a
|
||||
# slow/unresponsive interrupt can't hang this handler.
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"[api_server] stop for run %s timed out after 5s; "
|
||||
"agent may still be finishing the current step",
|
||||
run_id,
|
||||
)
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
return web.json_response({"run_id": run_id, "status": "stopping"})
|
||||
|
||||
async def _sweep_orphaned_runs(self) -> None:
|
||||
"""Periodically clean up run streams that were never consumed."""
|
||||
"""Periodically expire transport buffers and terminal status records."""
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
self._sweep_orphaned_runs_once(time.time())
|
||||
|
||||
def _sweep_orphaned_runs_once(self, now: Optional[float] = None) -> None:
|
||||
"""Expire old SSE buffers without treating transport age as run age."""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
stale = [
|
||||
run_id
|
||||
for run_id, created_at in list(self._run_streams_created.items())
|
||||
if now - created_at > self._RUN_STREAM_TTL
|
||||
]
|
||||
for run_id in stale:
|
||||
logger.debug("[api_server] sweeping orphaned run %s", run_id)
|
||||
stale = [
|
||||
run_id
|
||||
for run_id, created_at in list(self._run_streams_created.items())
|
||||
if now - created_at > self._RUN_STREAM_TTL
|
||||
and run_id not in self._run_stream_subscribers
|
||||
]
|
||||
for run_id in stale:
|
||||
logger.debug("[api_server] sweeping expired run transport %s", run_id)
|
||||
task = self._active_run_tasks.get(run_id)
|
||||
task_done = task is None or task.done()
|
||||
if task_done:
|
||||
try:
|
||||
from tools.approval import unregister_gateway_notify
|
||||
|
||||
|
|
@ -4724,20 +4759,24 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
unregister_gateway_notify(approval_session_key)
|
||||
except Exception:
|
||||
pass
|
||||
self._run_streams.pop(run_id, None)
|
||||
self._run_streams_created.pop(run_id, None)
|
||||
# The transport TTL always bounds buffering. Live control state is
|
||||
# independent and survives until the executor-backed task returns.
|
||||
self._run_streams.pop(run_id, None)
|
||||
self._run_streams_created.pop(run_id, None)
|
||||
if task_done:
|
||||
self._active_run_agents.pop(run_id, None)
|
||||
self._active_run_tasks.pop(run_id, None)
|
||||
self._run_approval_sessions.pop(run_id, None)
|
||||
self._stopping_run_ids.discard(run_id)
|
||||
|
||||
stale_statuses = [
|
||||
run_id
|
||||
for run_id, status in list(self._run_statuses.items())
|
||||
if status.get("status") in {"completed", "failed", "cancelled"}
|
||||
and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL
|
||||
]
|
||||
for run_id in stale_statuses:
|
||||
self._run_statuses.pop(run_id, None)
|
||||
stale_statuses = [
|
||||
run_id
|
||||
for run_id, status in list(self._run_statuses.items())
|
||||
if status.get("status") in {"completed", "failed", "cancelled"}
|
||||
and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL
|
||||
]
|
||||
for run_id in stale_statuses:
|
||||
self._run_statuses.pop(run_id, None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BasePlatformAdapter interface
|
||||
|
|
|
|||
|
|
@ -1298,6 +1298,27 @@ def get_auth_provider_display_name(provider_id: str) -> str:
|
|||
return SERVICE_PROVIDER_NAMES.get(normalized, provider_id)
|
||||
|
||||
|
||||
def is_runtime_provider_routable(provider_id: str) -> bool:
|
||||
"""Return whether runtime resolution recognizes a provider identity.
|
||||
|
||||
This is a capability check, not a credential check. It follows the same
|
||||
alias/plugin-aware normalization as ``resolve_provider`` while preserving
|
||||
special runtime identities that intentionally live outside the registry.
|
||||
"""
|
||||
normalized = (provider_id or "").strip().lower()
|
||||
if not normalized:
|
||||
return False
|
||||
if normalized in {"auto", "openrouter", "custom", "moa"}:
|
||||
return True
|
||||
if normalized.startswith("custom:"):
|
||||
return True
|
||||
try:
|
||||
resolve_provider(normalized)
|
||||
except AuthError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Return the persisted credential pool, or one provider slice.
|
||||
|
||||
|
|
|
|||
|
|
@ -1394,6 +1394,25 @@ import threading as _threading # noqa: E402
|
|||
_picker_prewarm_done = _threading.Event()
|
||||
|
||||
|
||||
def _credential_pool_is_usable(provider: str, *, raw_pool_present: bool = False) -> bool:
|
||||
"""Return whether *provider* has a credential that can be selected now.
|
||||
|
||||
``auth.json`` historically allowed opaque token-style pool values that do
|
||||
not deserialize into ``PooledCredential`` entries. Preserve visibility for
|
||||
those legacy values, but when a real pool exists its availability state is
|
||||
authoritative: an all-exhausted/dead pool is not authenticated.
|
||||
"""
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool(provider)
|
||||
if pool.has_credentials():
|
||||
return pool.has_available()
|
||||
except Exception:
|
||||
pass
|
||||
return raw_pool_present
|
||||
|
||||
|
||||
def _extra_headers_from_config(entry: Any) -> dict[str, str]:
|
||||
if not isinstance(entry, dict):
|
||||
return {}
|
||||
|
|
@ -1683,6 +1702,12 @@ def list_authenticated_providers(
|
|||
# section 2 (HERMES_OVERLAYS) with proper auth store checking.
|
||||
if pconfig and pconfig.auth_type != "api_key":
|
||||
continue
|
||||
# models.dev catalogs include providers Hermes may not route yet.
|
||||
# Gate on runtime capability rather than registry membership: special
|
||||
# providers and plugin aliases can be routable without a registry row.
|
||||
from hermes_cli.auth import is_runtime_provider_routable
|
||||
if not is_runtime_provider_routable(hermes_id):
|
||||
continue
|
||||
if pconfig and pconfig.api_key_env_vars:
|
||||
env_vars = list(pconfig.api_key_env_vars)
|
||||
else:
|
||||
|
|
@ -1696,8 +1721,13 @@ def list_authenticated_providers(
|
|||
try:
|
||||
from hermes_cli.auth import _load_auth_store
|
||||
store = _load_auth_store()
|
||||
if store and store.get("credential_pool", {}).get(hermes_id):
|
||||
has_creds = True
|
||||
raw_pool_present = bool(
|
||||
store and store.get("credential_pool", {}).get(hermes_id)
|
||||
)
|
||||
if raw_pool_present:
|
||||
has_creds = _credential_pool_is_usable(
|
||||
hermes_id, raw_pool_present=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if not has_creds:
|
||||
|
|
@ -1712,6 +1742,15 @@ def list_authenticated_providers(
|
|||
model_ids = curated.get(hermes_id, [])
|
||||
if hermes_id in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_id, model_ids)
|
||||
# A providers.<built-in>.models block extends the provider's discovered
|
||||
# catalog. Section 3 cannot emit it later because this built-in row owns
|
||||
# the slug, so merge declarations here before applying max_models.
|
||||
configured_models: list[str] = []
|
||||
if isinstance(user_providers, dict):
|
||||
configured = user_providers.get(hermes_id)
|
||||
if isinstance(configured, dict):
|
||||
configured_models = _declared_model_ids(configured.get("models"))
|
||||
model_ids = list(dict.fromkeys([*configured_models, *model_ids]))
|
||||
total = len(model_ids)
|
||||
if hermes_id in _UNCAPPED_PICKER_PROVIDERS:
|
||||
top = model_ids # Aggregator: show full catalog regardless of max_models
|
||||
|
|
@ -1786,9 +1825,7 @@ def list_authenticated_providers(
|
|||
# imports on demand but aren't in the raw auth.json yet.
|
||||
if not has_creds:
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool(hermes_slug)
|
||||
if pool.has_credentials():
|
||||
if _credential_pool_is_usable(hermes_slug):
|
||||
has_creds = True
|
||||
except Exception as exc:
|
||||
logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc)
|
||||
|
|
@ -1927,9 +1964,7 @@ def list_authenticated_providers(
|
|||
pass
|
||||
if not _cp_has_creds:
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
_cp_pool = load_pool(_cp.slug)
|
||||
if _cp_pool.has_credentials():
|
||||
if _credential_pool_is_usable(_cp.slug):
|
||||
_cp_has_creds = True
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@ from typing import Any, Dict, Optional
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
from hermes_cli import auth as auth_mod
|
||||
from agent.credential_pool import CredentialPool, PooledCredential, get_custom_provider_pool_key, load_pool
|
||||
from agent.credential_pool import (
|
||||
CredentialPool,
|
||||
PooledCredential,
|
||||
credential_pool_matches_provider,
|
||||
get_custom_provider_pool_key,
|
||||
load_pool,
|
||||
)
|
||||
from agent.secret_scope import get_secret as _get_secret
|
||||
from hermes_cli.auth import (
|
||||
AuthError,
|
||||
|
|
@ -1731,7 +1737,19 @@ def resolve_runtime_provider(
|
|||
if not pool_api_key or not _agent_key_is_usable(nous_state, min_ttl):
|
||||
logger.debug("Nous pool entry agent_key still unavailable, falling through to runtime resolution")
|
||||
pool_api_key = ""
|
||||
if entry is not None and pool_api_key:
|
||||
if (
|
||||
entry is not None
|
||||
and pool_api_key
|
||||
and credential_pool_matches_provider(
|
||||
pool,
|
||||
provider,
|
||||
base_url=(
|
||||
getattr(entry, "runtime_base_url", None)
|
||||
or getattr(entry, "base_url", None)
|
||||
or ""
|
||||
),
|
||||
)
|
||||
):
|
||||
return _resolve_runtime_from_pool_entry(
|
||||
provider=provider,
|
||||
entry=entry,
|
||||
|
|
|
|||
|
|
@ -516,7 +516,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
|||
GitHubAuth, create_source_router, ensure_hub_dirs,
|
||||
quarantine_bundle, install_from_quarantine, HubLockFile,
|
||||
)
|
||||
from tools.skills_guard import scan_skill, should_allow_install, format_scan_report
|
||||
from tools.skills_guard import scan_skill_cached, should_allow_install, format_scan_report
|
||||
|
||||
c = console or _console
|
||||
ensure_hub_dirs()
|
||||
|
|
@ -648,8 +648,24 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
|||
or getattr(meta, "identifier", "")
|
||||
or identifier
|
||||
)
|
||||
result = scan_skill(q_path, source=scan_source)
|
||||
from tools.skills_hub import HUB_DIR, source_url_for_bundle
|
||||
result, scan_provenance = scan_skill_cached(
|
||||
q_path,
|
||||
source=scan_source,
|
||||
source_url=source_url_for_bundle(bundle),
|
||||
cache_dir=HUB_DIR / "scan-cache",
|
||||
)
|
||||
c.print(format_scan_report(result))
|
||||
freshness = "fresh" if scan_provenance["fresh"] else "cached"
|
||||
c.print(
|
||||
f"[dim]Scan provenance: {freshness}; scanner "
|
||||
f"{scan_provenance['scanner_version']}; hash {scan_provenance['bundle_hash']}[/]"
|
||||
)
|
||||
rules = ", ".join(scan_provenance["rules"]) or "none"
|
||||
c.print(
|
||||
f"[dim]Source: {scan_provenance['source_url']}; scanned "
|
||||
f"{scan_provenance['scanned_at']}; rules: {rules}[/]"
|
||||
)
|
||||
|
||||
# Check install policy
|
||||
allowed, reason = should_allow_install(result, force=force)
|
||||
|
|
|
|||
|
|
@ -671,7 +671,7 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
|||
"approvals.mode": {
|
||||
"type": "select",
|
||||
"description": "Dangerous command approval mode",
|
||||
"options": ["ask", "yolo", "deny"],
|
||||
"options": ["manual", "smart", "off"],
|
||||
},
|
||||
"context.engine": {
|
||||
"type": "select",
|
||||
|
|
@ -1023,19 +1023,42 @@ def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, st
|
|||
``normalize_model_for_provider`` (e.g. ``anthropic/claude-opus-4.6``
|
||||
on native anthropic → ``claude-opus-4-6``).
|
||||
"""
|
||||
from hermes_cli.config import get_compatible_custom_providers
|
||||
from hermes_cli.models import _KNOWN_PROVIDER_NAMES, normalize_provider
|
||||
from hermes_cli.model_normalize import normalize_model_for_provider
|
||||
from hermes_cli.providers import resolve_custom_provider, resolve_user_provider
|
||||
|
||||
prov_in = (provider or "").strip()
|
||||
model_in = (model or "").strip()
|
||||
canonical = normalize_provider(prov_in)
|
||||
|
||||
# User-declared providers are real routing targets, not analytics vendor
|
||||
# labels. Resolve them before the unknown-vendor fallback. ``providers:``
|
||||
# keeps its declared bare slug; ``custom_providers:`` canonicalizes both a
|
||||
# bare display name and ``custom:<name>`` to the durable custom slug.
|
||||
try:
|
||||
cfg = load_config()
|
||||
except Exception:
|
||||
cfg = {}
|
||||
user_providers = cfg.get("providers") if isinstance(cfg, dict) else None
|
||||
user_provider = resolve_user_provider(
|
||||
prov_in, user_providers if isinstance(user_providers, dict) else {}
|
||||
)
|
||||
custom_provider = resolve_custom_provider(
|
||||
prov_in,
|
||||
get_compatible_custom_providers(cfg) if isinstance(cfg, dict) else [],
|
||||
)
|
||||
if user_provider is not None:
|
||||
return user_provider.id, model_in
|
||||
if custom_provider is not None:
|
||||
return custom_provider.id, model_in
|
||||
|
||||
if canonical not in _KNOWN_PROVIDER_NAMES and "/" in model_in:
|
||||
# Vendor prefix posing as a provider (analytics fallback). Resolve
|
||||
# against the user's current provider when it's an aggregator that
|
||||
# serves vendor-prefixed slugs; otherwise default to openrouter.
|
||||
try:
|
||||
cur_cfg = load_config().get("model", {})
|
||||
cur_cfg = cfg.get("model", {})
|
||||
cur_provider = (
|
||||
str(cur_cfg.get("provider", "") or "").strip().lower()
|
||||
if isinstance(cur_cfg, dict) else ""
|
||||
|
|
|
|||
64
plugins/kanban/dashboard/dist/index.js
vendored
64
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -1072,6 +1072,7 @@
|
|||
error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null,
|
||||
h(BoardColumns, {
|
||||
board: filteredBoard,
|
||||
boardMeta: boardList.find(function (item) { return item.slug === board; }) || null,
|
||||
laneByProfile,
|
||||
selectedIds,
|
||||
failedIds,
|
||||
|
|
@ -1887,6 +1888,7 @@
|
|||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
const [projectDirectory, setProjectDirectory] = useState("");
|
||||
const [switchTo, setSwitchTo] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState(null);
|
||||
|
|
@ -1911,6 +1913,7 @@
|
|||
name: name.trim() || autoName || undefined,
|
||||
description: description.trim() || undefined,
|
||||
icon: icon.trim() || undefined,
|
||||
default_workdir: projectDirectory.trim() || undefined,
|
||||
switch: switchTo,
|
||||
}).catch(function (e) {
|
||||
setErr(String(e && e.message ? e.message : e));
|
||||
|
|
@ -1966,6 +1969,27 @@
|
|||
className: "h-8",
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" },
|
||||
tx(t, "projectDirectory", "Project directory"), " ",
|
||||
h("span", { className: "text-muted-foreground" },
|
||||
tx(t, "projectDirectoryHint", "(recommended)"))),
|
||||
h(Input, {
|
||||
value: projectDirectory,
|
||||
onChange: function (e) { setProjectDirectory(e.target.value); },
|
||||
placeholder: tx(t, "projectDirectoryPlaceholder",
|
||||
"Absolute path to the project folder"),
|
||||
title: tx(t, "projectDirectoryHelp",
|
||||
"Git projects use preserved worktrees. Other folders use the directory directly. Leave blank only for temporary work."),
|
||||
className: "h-8",
|
||||
autoCapitalize: "none",
|
||||
autoCorrect: "off",
|
||||
spellCheck: false,
|
||||
}),
|
||||
h("div", { className: "text-xs text-muted-foreground" },
|
||||
tx(t, "projectDirectoryExplanation",
|
||||
"Sets the default location for task files so project output is preserved.")),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" }, tx(t, "icon", "Icon"), " ",
|
||||
h("span", { className: "text-muted-foreground" },
|
||||
|
|
@ -2384,6 +2408,7 @@
|
|||
return h(Column, {
|
||||
key: col.name,
|
||||
column: col,
|
||||
boardMeta: props.boardMeta,
|
||||
laneByProfile: props.laneByProfile,
|
||||
selectedIds: props.selectedIds,
|
||||
failedIds: props.failedIds,
|
||||
|
|
@ -2504,6 +2529,8 @@
|
|||
showCreate ? h(InlineCreate, {
|
||||
columnName: props.column.name,
|
||||
allTasks: props.allTasks,
|
||||
defaultWorkspaceKind: (props.boardMeta && props.boardMeta.default_workspace_kind) || "scratch",
|
||||
defaultWorkspacePath: (props.boardMeta && props.boardMeta.default_workdir) || "",
|
||||
onSubmit: function (body) {
|
||||
props.onCreate(body).then(function () { setShowCreate(false); });
|
||||
},
|
||||
|
|
@ -2745,12 +2772,13 @@
|
|||
const [priority, setPriority] = useState(0);
|
||||
const [parent, setParent] = useState("");
|
||||
const [skills, setSkills] = useState("");
|
||||
// Workspace controls. `scratch` (default) ignores path; `worktree` optionally
|
||||
// takes a path (dispatcher derives one from the assignee profile otherwise);
|
||||
// `dir` requires a path. Backend enforces the rule — we only hide/show the
|
||||
// input here to save vertical space in the common `scratch` case.
|
||||
const [workspaceKind, setWorkspaceKind] = useState("scratch");
|
||||
const [workspacePath, setWorkspacePath] = useState("");
|
||||
// A board with a configured workdir defaults to a persistent workspace:
|
||||
// worktree for git repositories, dir for ordinary directories. Boards
|
||||
// without one keep scratch for disposable research and ops tasks.
|
||||
const defaultWorkspaceKind = props.defaultWorkspaceKind || "scratch";
|
||||
const defaultWorkspacePath = props.defaultWorkspacePath || "";
|
||||
const [workspaceKind, setWorkspaceKind] = useState(defaultWorkspaceKind);
|
||||
const [workspacePath, setWorkspacePath] = useState(defaultWorkspacePath);
|
||||
// Goal-mode: when on, the dispatched worker runs the Ralph-style /goal
|
||||
// loop — a judge re-checks the card after each turn and the worker keeps
|
||||
// going in the same session until done, or the turn budget runs out
|
||||
|
|
@ -2793,15 +2821,15 @@
|
|||
}
|
||||
props.onSubmit(body);
|
||||
setTitle(""); setAssignee(""); setPriority(0); setParent(""); setSkills("");
|
||||
setWorkspaceKind("scratch"); setWorkspacePath("");
|
||||
setWorkspaceKind(defaultWorkspaceKind); setWorkspacePath(defaultWorkspacePath);
|
||||
setGoalMode(false); setGoalMaxTurns("");
|
||||
};
|
||||
|
||||
const showPathInput = workspaceKind !== "scratch";
|
||||
const pathPlaceholder = workspaceKind === "dir"
|
||||
? tx(t, "workspacePathDir", "workspace path (required, e.g. ~/projects/my-app)")
|
||||
? tx(t, "workspacePathDir", "workspace path (required without a board workdir)")
|
||||
: tx(t, "workspacePathOptional",
|
||||
"workspace path (optional, derived from assignee if blank)");
|
||||
"repository path (optional when the board has a workdir)");
|
||||
|
||||
return h("div", { className: "hermes-kanban-inline-create" },
|
||||
h("textarea", {
|
||||
|
|
@ -2877,12 +2905,15 @@
|
|||
h("div", { className: "flex gap-2" },
|
||||
h(Select, Object.assign({
|
||||
value: workspaceKind,
|
||||
title: "scratch: isolated temp dir (default). worktree: git worktree on the assignee profile. dir: exact path (required below).",
|
||||
className: "h-7 text-xs w-28",
|
||||
title: "Choose whether task files are temporary or preserved after completion.",
|
||||
className: "h-7 text-xs flex-1",
|
||||
}, selectChangeHandler(setWorkspaceKind)),
|
||||
h(SelectOption, { value: "scratch" }, "scratch"),
|
||||
h(SelectOption, { value: "worktree" }, "worktree"),
|
||||
h(SelectOption, { value: "dir" }, "dir"),
|
||||
h(SelectOption, { value: "scratch" },
|
||||
tx(t, "workspaceScratch", "Temporary — deleted on completion")),
|
||||
h(SelectOption, { value: "worktree" },
|
||||
tx(t, "workspaceWorktree", "Git worktree — preserved")),
|
||||
h(SelectOption, { value: "dir" },
|
||||
tx(t, "workspaceDir", "Directory — preserved")),
|
||||
),
|
||||
showPathInput ? h(Input, {
|
||||
value: workspacePath,
|
||||
|
|
@ -2891,6 +2922,11 @@
|
|||
className: "h-7 text-xs flex-1",
|
||||
}) : null,
|
||||
),
|
||||
workspaceKind === "scratch" ? h("div", {
|
||||
className: "text-xs text-destructive",
|
||||
role: "alert",
|
||||
}, tx(t, "workspaceScratchWarning",
|
||||
"This workspace and any files left in it are deleted when the task completes.")) : null,
|
||||
h(Select, Object.assign({
|
||||
value: parent,
|
||||
className: "h-7 text-xs",
|
||||
|
|
|
|||
|
|
@ -1982,6 +1982,7 @@ class CreateBoardBody(BaseModel):
|
|||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
default_workdir: Optional[str] = None
|
||||
switch: bool = False
|
||||
|
||||
|
||||
|
|
@ -2010,6 +2011,17 @@ def _board_counts(slug: str) -> dict[str, int]:
|
|||
return {}
|
||||
|
||||
|
||||
def _default_workspace_kind(board: dict[str, Any]) -> str:
|
||||
"""Recommend a non-destructive task workspace from board metadata."""
|
||||
workdir = str(board.get("default_workdir") or "").strip()
|
||||
if not workdir:
|
||||
return "scratch"
|
||||
try:
|
||||
return "worktree" if kanban_db._git_toplevel(Path(workdir)) else "dir"
|
||||
except (OSError, ValueError):
|
||||
return "dir"
|
||||
|
||||
|
||||
@router.get("/boards")
|
||||
def list_boards(include_archived: bool = Query(False)):
|
||||
"""Return every board on disk with task counts and the active slug."""
|
||||
|
|
@ -2019,12 +2031,27 @@ def list_boards(include_archived: bool = Query(False)):
|
|||
b["is_current"] = (b["slug"] == current)
|
||||
b["counts"] = _board_counts(b["slug"])
|
||||
b["total"] = sum(b["counts"].values())
|
||||
b["default_workspace_kind"] = _default_workspace_kind(b)
|
||||
return {"boards": boards, "current": current}
|
||||
|
||||
|
||||
@router.post("/boards")
|
||||
def create_board_endpoint(payload: CreateBoardBody):
|
||||
"""Create a new board. Idempotent — ``slug`` collision returns existing."""
|
||||
default_workdir = None
|
||||
if payload.default_workdir:
|
||||
requested = Path(payload.default_workdir).expanduser()
|
||||
if not requested.is_absolute():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Project directory must be an absolute path.",
|
||||
)
|
||||
if not requested.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Project directory must be an existing directory.",
|
||||
)
|
||||
default_workdir = str(requested.resolve())
|
||||
try:
|
||||
meta = kanban_db.create_board(
|
||||
payload.slug,
|
||||
|
|
@ -2032,6 +2059,7 @@ def create_board_endpoint(payload: CreateBoardBody):
|
|||
description=payload.description,
|
||||
icon=payload.icon,
|
||||
color=payload.color,
|
||||
default_workdir=default_workdir,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
|
@ -2040,6 +2068,7 @@ def create_board_endpoint(payload: CreateBoardBody):
|
|||
kanban_db.set_current_board(meta["slug"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
meta["default_workspace_kind"] = _default_workspace_kind(meta)
|
||||
return {"board": meta, "current": kanban_db.get_current_board()}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
|||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"VrtxOmega@pm.me": "VrtxOmega", # PR #43809 salvage (desktop: WSL folder-picker path bridge)
|
||||
"135129512+ansel-f@users.noreply.github.com": "ansel-f", # PR #62388 salvage (approval: allow exact verifier temp cleanup without broadening rm safety boundary)
|
||||
"robert@modern-minds.ai": "Hopfensaft", # PR #31933 salvage (dashboard: align approvals.mode dropdown with canonical engine values)
|
||||
"palmer@dugoutfantasy.com": "professorpalmer", # PR #48591 salvage (sessions: CLI workspace filter + restore-cwd-on-resume)
|
||||
"true@supersynergy.de": "Supersynergy", # PR #59241 salvage (desktop: workspace path status-bar action)
|
||||
"esthon@gmail.com": "esthonjr", # PR #61950 salvage (desktop: legacy non-git workspace grouping + Windows path identity)
|
||||
|
|
|
|||
64
tests/agent/test_credential_pool_provider_boundary.py
Normal file
64
tests/agent/test_credential_pool_provider_boundary.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Credential pools must never cross provider or custom-endpoint boundaries."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.credential_pool import credential_pool_matches_provider
|
||||
from hermes_cli import runtime_provider as rp
|
||||
|
||||
|
||||
def test_provider_match_requires_exact_non_custom_identity():
|
||||
assert credential_pool_matches_provider("deepseek", "deepseek")
|
||||
assert not credential_pool_matches_provider("openai-codex", "deepseek")
|
||||
assert not credential_pool_matches_provider("", "deepseek")
|
||||
|
||||
|
||||
def test_custom_pool_match_is_scoped_by_endpoint():
|
||||
with patch(
|
||||
"agent.credential_pool.get_custom_provider_pool_key",
|
||||
return_value="custom:lab",
|
||||
):
|
||||
assert credential_pool_matches_provider(
|
||||
"custom:lab", "custom", base_url="https://lab.example/v1"
|
||||
)
|
||||
assert not credential_pool_matches_provider(
|
||||
"custom:other", "custom", base_url="https://lab.example/v1"
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_ignores_pool_loaded_for_different_provider(monkeypatch):
|
||||
entry = SimpleNamespace(
|
||||
provider="openai-codex",
|
||||
access_token="wrong-token",
|
||||
runtime_api_key="wrong-token",
|
||||
runtime_base_url="https://chatgpt.com/backend-api/codex",
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
)
|
||||
pool = SimpleNamespace(
|
||||
provider="openai-codex",
|
||||
has_credentials=lambda: True,
|
||||
select=lambda: entry,
|
||||
)
|
||||
monkeypatch.setattr(rp, "load_pool", lambda _provider: pool)
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *_a, **_kw: "deepseek")
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"_get_model_config",
|
||||
lambda: {"provider": "deepseek", "default": "deepseek-chat"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"resolve_api_key_provider_credentials",
|
||||
lambda _provider: {
|
||||
"provider": "deepseek",
|
||||
"api_key": "deepseek-key",
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"source": "env",
|
||||
},
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="deepseek")
|
||||
|
||||
assert resolved["provider"] == "deepseek"
|
||||
assert resolved["api_key"] == "deepseek-key"
|
||||
assert resolved["base_url"] == "https://api.deepseek.com/v1"
|
||||
|
|
@ -260,6 +260,27 @@ def test_no_suite_nudge_requests_temp_script(tmp_path, monkeypatch):
|
|||
assert "creative UI/visual work" in nudge
|
||||
|
||||
|
||||
def test_no_suite_nudge_uses_canonical_temp_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / "package.json").write_text("{}", encoding="utf-8")
|
||||
real_temp = tmp_path / "real-temp"
|
||||
real_temp.mkdir()
|
||||
linked_temp = tmp_path / "linked-temp"
|
||||
linked_temp.symlink_to(real_temp, target_is_directory=True)
|
||||
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(linked_temp))
|
||||
|
||||
nudge = build_verify_on_stop_nudge(
|
||||
session_id="s1",
|
||||
changed_paths=[str(project / "src" / "app.ts")],
|
||||
)
|
||||
|
||||
assert nudge is not None
|
||||
assert str(real_temp) in nudge
|
||||
assert str(linked_temp) not in nudge
|
||||
|
||||
|
||||
def test_verify_guidance_can_be_disabled(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
_node_project(tmp_path)
|
||||
|
|
|
|||
|
|
@ -568,14 +568,29 @@ class TestConcurrencyCap:
|
|||
assert resp.headers.get("Retry-After")
|
||||
|
||||
def test_cap_counts_both_buckets(self):
|
||||
# /v1/runs (tracked by _run_streams) + chat/responses (inflight)
|
||||
# /v1/runs (tracked by live tasks) + chat/responses (inflight)
|
||||
adapter = _make_adapter()
|
||||
adapter._max_concurrent_runs = 4
|
||||
adapter._inflight_agent_runs = 2
|
||||
adapter._run_streams = {"r1": object(), "r2": object()}
|
||||
resp = adapter._concurrency_limited_response()
|
||||
assert resp is not None
|
||||
assert resp.status == 429
|
||||
|
||||
async def _assert_live_tasks_are_counted_without_streams():
|
||||
blocker = asyncio.Event()
|
||||
|
||||
async def _live_run():
|
||||
await blocker.wait()
|
||||
|
||||
tasks = [asyncio.create_task(_live_run()) for _ in range(2)]
|
||||
adapter._active_run_tasks = {f"r{i}": task for i, task in enumerate(tasks)}
|
||||
adapter._run_streams = {}
|
||||
try:
|
||||
resp = adapter._concurrency_limited_response()
|
||||
assert resp is not None
|
||||
assert resp.status == 429
|
||||
finally:
|
||||
blocker.set()
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
asyncio.run(_assert_live_tasks_are_counted_without_streams())
|
||||
|
||||
def test_zero_disables_cap(self):
|
||||
adapter = _make_adapter()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ Covers:
|
|||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -442,12 +443,229 @@ class TestRunEvents:
|
|||
assert resp.status == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run lifecycle TTL sweeping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunLifecycleSweep:
|
||||
def test_sweep_keeps_transport_with_active_subscriber(self, adapter):
|
||||
run_id = "run_subscribed"
|
||||
queue = asyncio.Queue()
|
||||
adapter._run_streams[run_id] = queue
|
||||
adapter._run_streams_created[run_id] = 0
|
||||
adapter._run_stream_subscribers.add(run_id)
|
||||
|
||||
adapter._sweep_orphaned_runs_once(time.time())
|
||||
|
||||
assert adapter._run_streams[run_id] is queue
|
||||
assert run_id in adapter._run_streams_created
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_live_run_drops_transport_but_keeps_control_state(self, adapter):
|
||||
"""Stream TTL bounds buffering without detaching a live run."""
|
||||
app = _create_runs_app(adapter)
|
||||
adapter._max_concurrent_runs = 1
|
||||
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(adapter, "_create_agent") as mock_create:
|
||||
mock_agent, agent_ready, _ = _make_slow_agent()
|
||||
mock_create.return_value = mock_agent
|
||||
|
||||
start_resp = await cli.post("/v1/runs", json={"input": "hello"})
|
||||
assert start_resp.status == 202
|
||||
run_id = (await start_resp.json())["run_id"]
|
||||
assert agent_ready.wait(timeout=3.0)
|
||||
|
||||
task = adapter._active_run_tasks[run_id]
|
||||
assert isinstance(task, asyncio.Task)
|
||||
assert not task.done()
|
||||
|
||||
pending = approval_mod._ApprovalEntry({
|
||||
"command": "bash -c long-running",
|
||||
"description": "approval after stream TTL",
|
||||
"pattern_keys": ["shell-c"],
|
||||
})
|
||||
with approval_mod._lock:
|
||||
approval_mod._gateway_queues[run_id] = [pending]
|
||||
|
||||
adapter._run_streams_created[run_id] -= adapter._RUN_STREAM_TTL + 1
|
||||
# Exercise one real sweeper iteration without waiting 60 seconds.
|
||||
with patch(
|
||||
"gateway.platforms.api_server.asyncio.sleep",
|
||||
side_effect=[None, asyncio.CancelledError()],
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await adapter._sweep_orphaned_runs()
|
||||
|
||||
assert adapter._active_run_tasks[run_id] is task
|
||||
assert adapter._active_run_agents[run_id] is mock_agent
|
||||
assert run_id not in adapter._run_streams
|
||||
assert run_id not in adapter._run_streams_created
|
||||
assert adapter._run_approval_sessions[run_id] == run_id
|
||||
|
||||
limited = adapter._concurrency_limited_response()
|
||||
assert limited is not None
|
||||
assert limited.status == 429
|
||||
|
||||
approval_resp = await cli.post(
|
||||
f"/v1/runs/{run_id}/approval",
|
||||
json={"choice": "once"},
|
||||
)
|
||||
assert approval_resp.status == 200
|
||||
assert pending.event.is_set()
|
||||
assert pending.result == "once"
|
||||
|
||||
stop_resp = await cli.post(f"/v1/runs/{run_id}/stop")
|
||||
assert stop_resp.status == 200
|
||||
mock_agent.interrupt.assert_called_once_with("Stop requested via API")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_transport_stops_buffering_new_deltas(self, adapter):
|
||||
"""An unconsumed expired queue must not grow for the rest of a live run."""
|
||||
app = _create_runs_app(adapter)
|
||||
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(adapter, "_create_agent") as mock_create:
|
||||
mock_agent, agent_ready, _ = _make_slow_agent()
|
||||
mock_create.return_value = mock_agent
|
||||
|
||||
start_resp = await cli.post("/v1/runs", json={"input": "hello"})
|
||||
run_id = (await start_resp.json())["run_id"]
|
||||
assert agent_ready.wait(timeout=3.0)
|
||||
expired_queue = adapter._run_streams[run_id]
|
||||
stream_delta = mock_create.call_args.kwargs["stream_delta_callback"]
|
||||
|
||||
adapter._run_streams_created[run_id] -= adapter._RUN_STREAM_TTL + 1
|
||||
adapter._sweep_orphaned_runs_once(time.time())
|
||||
before = expired_queue.qsize()
|
||||
stream_delta("must-not-buffer")
|
||||
mock_agent.interrupt("finish test")
|
||||
for _ in range(40):
|
||||
if run_id not in adapter._active_run_tasks:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert expired_queue.qsize() == before
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_orphan_run_state_is_reaped(self, adapter):
|
||||
run_id = "run_expired_orphan"
|
||||
adapter._run_streams[run_id] = asyncio.Queue()
|
||||
adapter._run_streams_created[run_id] = 0
|
||||
adapter._run_approval_sessions[run_id] = run_id
|
||||
|
||||
pending = approval_mod._ApprovalEntry({
|
||||
"command": "bash -c orphaned",
|
||||
"description": "orphaned approval",
|
||||
"pattern_keys": ["shell-c"],
|
||||
})
|
||||
with approval_mod._lock:
|
||||
approval_mod._gateway_queues[run_id] = [pending]
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.api_server.asyncio.sleep",
|
||||
side_effect=[None, asyncio.CancelledError()],
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await adapter._sweep_orphaned_runs()
|
||||
|
||||
assert run_id not in adapter._run_streams
|
||||
assert run_id not in adapter._run_streams_created
|
||||
assert run_id not in adapter._run_approval_sessions
|
||||
assert pending.event.is_set()
|
||||
with approval_mod._lock:
|
||||
assert run_id not in approval_mod._gateway_queues
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /v1/runs/{run_id}/stop — interrupt a running agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStopRun:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_before_agent_creation_prevents_run_start(self, adapter):
|
||||
"""A stop accepted while queued must prevent agent construction."""
|
||||
app = _create_runs_app(adapter)
|
||||
original_create_task = asyncio.create_task
|
||||
task_started = asyncio.Event()
|
||||
allow_task = asyncio.Event()
|
||||
|
||||
def _delayed_create_task(coro):
|
||||
async def _delayed():
|
||||
task_started.set()
|
||||
await allow_task.wait()
|
||||
return await coro
|
||||
|
||||
return original_create_task(_delayed())
|
||||
|
||||
with patch("gateway.platforms.api_server.asyncio.create_task", side_effect=_delayed_create_task), \
|
||||
patch.object(adapter, "_create_agent") as mock_create:
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.post("/v1/runs", json={"input": "hello"})
|
||||
run_id = (await resp.json())["run_id"]
|
||||
await task_started.wait()
|
||||
|
||||
stop_resp = await cli.post(f"/v1/runs/{run_id}/stop")
|
||||
assert stop_resp.status == 200
|
||||
allow_task.set()
|
||||
|
||||
for _ in range(20):
|
||||
if run_id not in adapter._active_run_tasks:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
mock_create.assert_not_called()
|
||||
assert adapter._run_statuses[run_id]["status"] == "cancelled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_keeps_uncooperative_executor_tracked_until_exit(self, adapter):
|
||||
"""Cancelling an asyncio wrapper must not hide its live executor thread."""
|
||||
app = _create_runs_app(adapter)
|
||||
run_can_finish = threading.Event()
|
||||
run_finished = threading.Event()
|
||||
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(adapter, "_create_agent") as mock_create:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.session_prompt_tokens = 0
|
||||
mock_agent.session_completion_tokens = 0
|
||||
mock_agent.session_total_tokens = 0
|
||||
started = threading.Event()
|
||||
|
||||
def _run_conversation(*_args, **_kwargs):
|
||||
started.set()
|
||||
run_can_finish.wait(timeout=5)
|
||||
run_finished.set()
|
||||
return {"final_response": "late result"}
|
||||
|
||||
mock_agent.run_conversation.side_effect = _run_conversation
|
||||
mock_create.return_value = mock_agent
|
||||
|
||||
resp = await cli.post("/v1/runs", json={"input": "hello"})
|
||||
run_id = (await resp.json())["run_id"]
|
||||
assert started.wait(timeout=3)
|
||||
|
||||
stop_resp = await cli.post(f"/v1/runs/{run_id}/stop")
|
||||
assert stop_resp.status == 200
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert not run_finished.is_set()
|
||||
assert run_id in adapter._active_run_agents
|
||||
assert run_id in adapter._active_run_tasks
|
||||
assert adapter._run_statuses[run_id]["status"] == "stopping"
|
||||
|
||||
run_can_finish.set()
|
||||
for _ in range(40):
|
||||
if run_id not in adapter._active_run_tasks:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert run_id not in adapter._active_run_agents
|
||||
assert run_id not in adapter._active_run_tasks
|
||||
assert adapter._run_statuses[run_id]["status"] == "cancelled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_running_agent(self, adapter):
|
||||
"""Stop should interrupt the agent and cancel the task."""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
"""Regression test for #45759.
|
||||
|
||||
An all-exhausted credential pool holds entries but no *usable* credential.
|
||||
``list_authenticated_providers`` must not treat such a provider as
|
||||
authenticated -- otherwise an aggregator whose quota is spent gets matched
|
||||
during no-provider ``/model`` resolution, wins the model name, and sticks as
|
||||
the session provider (the "sticky provider fallback pollution" bug).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _FakePool:
|
||||
def __init__(self, available: bool):
|
||||
self._available = available
|
||||
|
||||
def has_credentials(self) -> bool:
|
||||
# The pool still holds entries...
|
||||
return True
|
||||
|
||||
def has_available(self) -> bool:
|
||||
# ...but none of them are usable when exhausted/dead.
|
||||
return self._available
|
||||
|
||||
|
||||
def _patch_opencode_pool(monkeypatch, *, available: bool):
|
||||
"""Make the opencode-go aggregator look configured but with a pool whose
|
||||
only credential is (un)available, depending on ``available``."""
|
||||
import hermes_cli.auth as auth
|
||||
import agent.credential_pool as cp
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"_load_auth_store",
|
||||
lambda: {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": None,
|
||||
"credential_pool": {"opencode-go": {"entries": [{"id": "x"}]}},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cp,
|
||||
"load_pool",
|
||||
lambda provider: _FakePool(available if provider == "opencode-go" else True),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _strip_provider_env(monkeypatch):
|
||||
"""Don't let real provider keys in the environment authenticate providers
|
||||
through a different code path than the pool gate under test."""
|
||||
import os
|
||||
|
||||
for key in list(os.environ):
|
||||
if "OPENCODE" in key or key.endswith("_API_KEY"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def test_exhausted_pool_provider_is_not_authenticated(monkeypatch):
|
||||
"""The fix: an exhausted pool is NOT authenticated. Fails on main, where
|
||||
the gate accepted any stored pool entry regardless of usability."""
|
||||
from hermes_cli.model_switch import get_authenticated_provider_slugs
|
||||
|
||||
_patch_opencode_pool(monkeypatch, available=False)
|
||||
slugs = get_authenticated_provider_slugs(current_provider="alibaba")
|
||||
assert "opencode-go" not in slugs
|
||||
|
||||
|
||||
def test_pool_provider_with_available_credential_is_authenticated(monkeypatch):
|
||||
"""Control: with a usable credential the provider IS authenticated, proving
|
||||
the test drives the credential gate rather than excluding it for some other
|
||||
reason."""
|
||||
from hermes_cli.model_switch import get_authenticated_provider_slugs
|
||||
|
||||
_patch_opencode_pool(monkeypatch, available=True)
|
||||
slugs = get_authenticated_provider_slugs(current_provider="alibaba")
|
||||
assert "opencode-go" in slugs
|
||||
|
||||
|
||||
def test_opaque_legacy_pool_value_stays_visible(monkeypatch):
|
||||
"""Legacy token-style auth-store values have no parsed pool entries."""
|
||||
from hermes_cli.model_switch import _credential_pool_is_usable
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool.load_pool",
|
||||
lambda _provider: type(
|
||||
"EmptyPool",
|
||||
(),
|
||||
{
|
||||
"has_credentials": lambda self: False,
|
||||
"has_available": lambda self: False,
|
||||
},
|
||||
)(),
|
||||
)
|
||||
|
||||
assert _credential_pool_is_usable("opencode-go", raw_pool_present=True)
|
||||
44
tests/hermes_cli/test_configured_builtin_models.py
Normal file
44
tests/hermes_cli/test_configured_builtin_models.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Configured models extend built-in picker rows."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
|
||||
def _provider_row(configured_models, *, max_models=None):
|
||||
with (
|
||||
patch(
|
||||
"agent.models_dev.fetch_models_dev",
|
||||
return_value={"deepseek": {"env": ["DEEPSEEK_API_KEY"], "name": "DeepSeek"}},
|
||||
),
|
||||
patch(
|
||||
"agent.models_dev.PROVIDER_TO_MODELS_DEV",
|
||||
{"deepseek": "deepseek"},
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.models.cached_provider_model_ids",
|
||||
return_value=["live-a", "shared"],
|
||||
),
|
||||
patch("hermes_cli.providers.HERMES_OVERLAYS", {}),
|
||||
patch.dict("os.environ", {"DEEPSEEK_API_KEY": "test-key"}),
|
||||
):
|
||||
rows = list_authenticated_providers(
|
||||
current_provider="deepseek",
|
||||
user_providers={"deepseek": {"models": configured_models}},
|
||||
max_models=max_models,
|
||||
)
|
||||
return next(row for row in rows if row["slug"] == "deepseek")
|
||||
|
||||
|
||||
def test_configured_models_precede_and_deduplicate_discovered_models():
|
||||
row = _provider_row({"configured-x": {}, "shared": {}})
|
||||
|
||||
assert row["models"] == ["configured-x", "shared", "live-a"]
|
||||
assert row["total_models"] == 3
|
||||
|
||||
|
||||
def test_configured_models_are_merged_before_picker_limit():
|
||||
row = _provider_row(["configured-x", "configured-y"], max_models=2)
|
||||
|
||||
assert row["models"] == ["configured-x", "configured-y"]
|
||||
assert row["total_models"] == 4
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"""Dashboard main-model writes preserve declared provider identities."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.web_server import _normalize_main_model_assignment
|
||||
|
||||
|
||||
def _normalize(config, provider, model="vendor/model-a"):
|
||||
with patch("hermes_cli.web_server.load_config", return_value=config):
|
||||
return _normalize_main_model_assignment(provider, model)
|
||||
|
||||
|
||||
def test_providers_block_keeps_declared_bare_slug():
|
||||
result = _normalize(
|
||||
{"providers": {"commandcode": {"base_url": "http://localhost:55990/v1"}}},
|
||||
"commandcode",
|
||||
)
|
||||
|
||||
assert result == ("commandcode", "vendor/model-a")
|
||||
|
||||
|
||||
def test_custom_provider_name_canonicalizes_to_durable_slug():
|
||||
config = {
|
||||
"custom_providers": [
|
||||
{"name": "US Azure", "base_url": "http://localhost:18025/v1"}
|
||||
]
|
||||
}
|
||||
|
||||
assert _normalize(config, "US Azure") == ("custom:us-azure", "vendor/model-a")
|
||||
assert _normalize(config, "custom:us-azure") == (
|
||||
"custom:us-azure",
|
||||
"vendor/model-a",
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_vendor_still_uses_aggregator_fallback():
|
||||
assert _normalize({}, "unconfigured-vendor") == (
|
||||
"openrouter",
|
||||
"vendor/model-a",
|
||||
)
|
||||
44
tests/hermes_cli/test_model_switch_filter_unresolved.py
Normal file
44
tests/hermes_cli/test_model_switch_filter_unresolved.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Picker rows must resolve to a runtime provider (#57503)."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.auth import is_runtime_provider_routable
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
|
||||
def _rows_with_env(monkeypatch, env_name: str, provider: str) -> list[dict]:
|
||||
monkeypatch.setenv(env_name, "test-key")
|
||||
with (
|
||||
patch(
|
||||
"agent.models_dev.fetch_models_dev",
|
||||
return_value={provider: {"env": [env_name], "name": provider.title()}},
|
||||
),
|
||||
patch(
|
||||
"agent.models_dev.PROVIDER_TO_MODELS_DEV",
|
||||
{provider: provider},
|
||||
),
|
||||
patch("hermes_cli.models.cached_provider_model_ids", return_value=["model-a"]),
|
||||
patch("hermes_cli.providers.HERMES_OVERLAYS", {}),
|
||||
):
|
||||
return list_authenticated_providers(max_models=5)
|
||||
|
||||
|
||||
def test_models_dev_only_provider_is_not_selectable(monkeypatch):
|
||||
rows = _rows_with_env(monkeypatch, "MISTRAL_API_KEY", "mistral")
|
||||
|
||||
assert all(row["slug"] != "mistral" for row in rows)
|
||||
assert not is_runtime_provider_routable("mistral")
|
||||
|
||||
|
||||
def test_registered_provider_remains_selectable(monkeypatch):
|
||||
rows = _rows_with_env(monkeypatch, "DEEPSEEK_API_KEY", "deepseek")
|
||||
|
||||
row = next(row for row in rows if row["slug"] == "deepseek")
|
||||
assert row["models"] == ["model-a"]
|
||||
assert row["total_models"] == 1
|
||||
assert is_runtime_provider_routable("deepseek")
|
||||
|
||||
|
||||
def test_special_runtime_provider_does_not_require_registry_membership():
|
||||
assert is_runtime_provider_routable("openrouter")
|
||||
assert is_runtime_provider_routable("custom:local-lab")
|
||||
|
|
@ -3539,6 +3539,26 @@ class TestBuildSchemaFromConfig:
|
|||
assert "options" in entry
|
||||
assert "local" in entry["options"]
|
||||
|
||||
def test_approvals_mode_options_match_config_values(self):
|
||||
"""approvals.mode select options must match the values accepted by config.py.
|
||||
|
||||
Previously the dashboard showed ['ask', 'yolo', 'deny'] which are stale
|
||||
names that don't correspond to any real config value. The correct values
|
||||
are 'manual', 'smart', and 'off' (see hermes_cli/config.py).
|
||||
'smart' was missing entirely, making it unreachable from the UI.
|
||||
"""
|
||||
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||
entry = CONFIG_SCHEMA["approvals.mode"]
|
||||
assert entry["type"] == "select"
|
||||
options = entry["options"]
|
||||
assert "manual" in options, "'manual' missing from approvals.mode options"
|
||||
assert "smart" in options, "'smart' missing from approvals.mode options"
|
||||
assert "off" in options, "'off' missing from approvals.mode options"
|
||||
# Stale names that were previously shown but don't match config values
|
||||
assert "ask" not in options, "stale option 'ask' should not appear"
|
||||
assert "yolo" not in options, "stale option 'yolo' should not appear"
|
||||
assert "deny" not in options, "stale option 'deny' should not appear"
|
||||
|
||||
def test_empty_prefix_produces_correct_keys(self):
|
||||
from hermes_cli.web_server import _build_schema_from_config
|
||||
test_config = {"model": "test", "nested": {"key": "val"}}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
|||
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
|
@ -114,6 +115,102 @@ def test_create_task_appears_on_board(client):
|
|||
assert "researcher" in data["assignees"]
|
||||
|
||||
|
||||
def test_board_list_recommends_persistent_workspace_for_configured_workdir(
|
||||
client, tmp_path
|
||||
):
|
||||
"""Board metadata should tell the UI which safe task default to use."""
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q", str(repo)], check=True)
|
||||
kb.write_board_metadata("default", default_workdir=str(repo))
|
||||
|
||||
plain_dir = tmp_path / "notes"
|
||||
plain_dir.mkdir()
|
||||
kb.create_board("notes", default_workdir=str(plain_dir))
|
||||
kb.create_board("disposable")
|
||||
|
||||
response = client.get("/api/plugins/kanban/boards")
|
||||
|
||||
assert response.status_code == 200
|
||||
boards = {board["slug"]: board for board in response.json()["boards"]}
|
||||
assert boards["default"]["default_workspace_kind"] == "worktree"
|
||||
assert boards["notes"]["default_workspace_kind"] == "dir"
|
||||
assert boards["disposable"]["default_workspace_kind"] == "scratch"
|
||||
|
||||
|
||||
def test_create_board_persists_project_directory(client, tmp_path):
|
||||
"""The dashboard board form should anchor future tasks to its project."""
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
|
||||
response = client.post(
|
||||
"/api/plugins/kanban/boards",
|
||||
json={
|
||||
"slug": "project-board",
|
||||
"name": "Project Board",
|
||||
"default_workdir": str(project_dir),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
board = response.json()["board"]
|
||||
assert board["default_workdir"] == str(project_dir.resolve())
|
||||
assert board["default_workspace_kind"] == "dir"
|
||||
assert kb.read_board_metadata("project-board")["default_workdir"] == str(
|
||||
project_dir.resolve()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["relative/project", "~/missing-project"])
|
||||
def test_create_board_rejects_invalid_project_directory(client, path):
|
||||
"""A board must not persist a path that cannot anchor worker output."""
|
||||
response = client.post(
|
||||
"/api/plugins/kanban/boards",
|
||||
json={"slug": "invalid-project", "default_workdir": path},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "project directory" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_new_board_dialog_collects_project_directory():
|
||||
"""Board creation should expose the setting that controls safe task defaults."""
|
||||
bundle = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "plugins"
|
||||
/ "kanban"
|
||||
/ "dashboard"
|
||||
/ "dist"
|
||||
/ "index.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert 'const [projectDirectory, setProjectDirectory] = useState("");' in bundle
|
||||
assert "Project directory" in bundle
|
||||
assert "Absolute path to the project folder" in bundle
|
||||
assert "default_workdir: projectDirectory.trim() || undefined" in bundle
|
||||
|
||||
|
||||
def test_dashboard_workspace_picker_explains_persistence_contract():
|
||||
"""Task creation must make scratch deletion visible without a hover."""
|
||||
bundle = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "plugins"
|
||||
/ "kanban"
|
||||
/ "dashboard"
|
||||
/ "dist"
|
||||
/ "index.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "Temporary — deleted on completion" in bundle
|
||||
assert "Git worktree — preserved" in bundle
|
||||
assert "Directory — preserved" in bundle
|
||||
assert "defaultWorkspacePath: (props.boardMeta && props.boardMeta.default_workdir) || \"\"" in bundle
|
||||
assert (
|
||||
"This workspace and any files left in it are deleted when the task completes."
|
||||
in bundle
|
||||
)
|
||||
|
||||
|
||||
def test_scheduled_tasks_have_their_own_column_not_todo(client):
|
||||
"""Scheduled/time-delay tasks must not be silently bucketed into todo."""
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import ast
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
|
@ -116,6 +117,53 @@ class TestDetectDangerousRm:
|
|||
assert key is not None
|
||||
assert "delete" in desc.lower()
|
||||
|
||||
def test_nonrecursive_verification_artifact_cleanup_is_not_dangerous(self):
|
||||
with mock_patch("tempfile.gettempdir", return_value="/tmp"):
|
||||
for prefix in ("hermes-verify-", "hermes-ad-hoc-"):
|
||||
assert detect_dangerous_command(f"rm -f /tmp/{prefix}example.py") == (
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
def test_symlinked_temp_dir_only_exempts_canonical_target(self, tmp_path):
|
||||
real_temp = tmp_path / "real-temp"
|
||||
real_temp.mkdir()
|
||||
linked_temp = tmp_path / "linked-temp"
|
||||
linked_temp.symlink_to(real_temp, target_is_directory=True)
|
||||
basename = "hermes-verify-example.py"
|
||||
|
||||
with mock_patch("tempfile.gettempdir", return_value=str(linked_temp)):
|
||||
assert detect_dangerous_command(f"rm -f {linked_temp / basename}")[0] is True
|
||||
assert detect_dangerous_command(f"rm -f {real_temp / basename}") == (
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
def test_verification_cleanup_exemption_rejects_broader_deletions(self):
|
||||
commands = (
|
||||
"rm -rf /tmp/hermes-verify-example.py",
|
||||
"rm -f /tmp/hermes-verify-example.py /tmp/other.py",
|
||||
"rm -f /tmp/nested/hermes-verify-example.py",
|
||||
"rm -f /tmp/nested/../hermes-verify-example.py",
|
||||
"rm -f /tmp/./hermes-verify-example.py",
|
||||
"rm -f /tmp//hermes-verify-example.py",
|
||||
"rm -f /tmp/a/../../tmp/hermes-verify-example.py",
|
||||
"rm -f /var/tmp/hermes-verify-example.py",
|
||||
"rm -f /tmp/unrelated.py",
|
||||
"rm -f /tmp/hermes-verify-*",
|
||||
"rm -f /tmp/hermes-verify-$(touch>/tmp/pwned).py",
|
||||
"rm -f /tmp/hermes-ad-hoc-`touch>/tmp/pwned`.py",
|
||||
"rm -f /tmp/hermes-verify-example.py; touch /tmp/pwned",
|
||||
)
|
||||
with mock_patch("tempfile.gettempdir", return_value="/tmp"):
|
||||
for command in commands:
|
||||
is_dangerous, key, desc = detect_dangerous_command(command)
|
||||
assert is_dangerous is True, command
|
||||
assert key is not None, command
|
||||
assert "delete" in desc.lower(), command
|
||||
|
||||
|
||||
class TestWindowsShellDestructiveCommands:
|
||||
def test_cmd_del_requires_approval(self):
|
||||
|
|
|
|||
|
|
@ -467,6 +467,44 @@ class TestShellFileOpsHelpers:
|
|||
# Should be safely escaped
|
||||
assert result.count("'") >= 4 # wrapping + escaping
|
||||
|
||||
def test_escape_shell_arg_rewrites_windows_drive_paths_to_msys(self, monkeypatch, file_ops):
|
||||
# bash eats backslashes and MSYS mangles ``C:\...``; the Git Bash
|
||||
# ``/c/...`` form is the reliable one (reuses _windows_to_msys_path).
|
||||
import tools.environments.local as local_mod
|
||||
|
||||
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
|
||||
assert file_ops._escape_shell_arg(r"C:\Users\alice\notes.txt") == "'/c/Users/alice/notes.txt'"
|
||||
# Non-drive paths are untouched.
|
||||
assert file_ops._escape_shell_arg("/tmp/foo") == "'/tmp/foo'"
|
||||
|
||||
def test_read_file_uses_bash_safe_windows_paths(self, mock_env, monkeypatch):
|
||||
import tools.environments.local as local_mod
|
||||
|
||||
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
|
||||
commands = []
|
||||
|
||||
def side_effect(command, **kwargs):
|
||||
commands.append(command)
|
||||
if command.startswith("wc -c"):
|
||||
return {"output": "5\n", "returncode": 0}
|
||||
if command.startswith("head -c"):
|
||||
return {"output": "hello", "returncode": 0}
|
||||
if command.startswith("sed -n"):
|
||||
return {"output": "hello\n", "returncode": 0}
|
||||
if command.startswith("wc -l"):
|
||||
return {"output": "1\n", "returncode": 0}
|
||||
return {"output": "", "returncode": 0}
|
||||
|
||||
mock_env.execute.side_effect = side_effect
|
||||
ops = ShellFileOperations(mock_env)
|
||||
result = ops.read_file(r"C:\Users\alice\notes.txt")
|
||||
|
||||
assert result.error is None
|
||||
assert commands[0] == "wc -c < '/c/Users/alice/notes.txt' 2>/dev/null"
|
||||
assert commands[1] == "head -c 1000 '/c/Users/alice/notes.txt' 2>/dev/null"
|
||||
assert commands[2] == "sed -n '1,500p' '/c/Users/alice/notes.txt'"
|
||||
assert commands[3] == "wc -l < '/c/Users/alice/notes.txt'"
|
||||
|
||||
def test_is_likely_binary_by_extension(self, file_ops):
|
||||
assert file_ops._is_likely_binary("photo.png") is True
|
||||
assert file_ops._is_likely_binary("data.db") is True
|
||||
|
|
|
|||
260
tests/tools/test_skill_bundle_provenance.py
Normal file
260
tests/tools/test_skill_bundle_provenance.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
"""Multi-file third-party skill bundles and scanner provenance (#60598)."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from tools.skills_guard import SCANNER_VERSION, scan_skill_cached
|
||||
from tools.skills_hub import GitHubAuth, GitHubSource, HubLockFile, SkillBundle, UrlSource
|
||||
|
||||
|
||||
SKILL_MD = """---
|
||||
name: demo-bundle
|
||||
description: A multi-file test skill.
|
||||
---
|
||||
# Demo
|
||||
Read [the guide](references/guide.md#usage), use `templates/report.md?raw=1`, and run
|
||||
`scripts/run.py`. See `examples/endpoint-inventory.md`. The repository also
|
||||
contains assets/logo.png.
|
||||
"""
|
||||
|
||||
|
||||
class _QuietHandler(SimpleHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def served_repo(tmp_path):
|
||||
repo = tmp_path / "upstream"
|
||||
repo.mkdir()
|
||||
(repo / "SKILL.md").write_text(SKILL_MD)
|
||||
for rel, content in {
|
||||
"references/guide.md": "safe guide\n",
|
||||
"templates/report.md": "report\n",
|
||||
"scripts/run.py": "print('ok')\n",
|
||||
"assets/logo.png": b"\x89PNG\r\n\x1a\n\x00\xff",
|
||||
"examples/endpoint-inventory.md": "example\n",
|
||||
"examples/not-installed.md": "must not be copied\n",
|
||||
"README.md": "must not be copied\n",
|
||||
}.items():
|
||||
path = repo / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if isinstance(content, bytes):
|
||||
path.write_bytes(content)
|
||||
else:
|
||||
path.write_text(content)
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "fixture"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
server = ThreadingHTTPServer(
|
||||
("127.0.0.1", 0), partial(_QuietHandler, directory=str(repo))
|
||||
)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield repo, f"http://127.0.0.1:{server.server_port}/SKILL.md"
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
|
||||
|
||||
def test_url_source_fetches_only_referenced_allowed_support_directories(served_repo, monkeypatch):
|
||||
_repo, url = served_repo
|
||||
monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
|
||||
monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
|
||||
|
||||
bundle = UrlSource().fetch(url)
|
||||
|
||||
assert bundle is not None
|
||||
assert set(bundle.files) == {
|
||||
"SKILL.md",
|
||||
"references/guide.md",
|
||||
"templates/report.md",
|
||||
"scripts/run.py",
|
||||
"assets/logo.png",
|
||||
"examples/endpoint-inventory.md",
|
||||
}
|
||||
assert bundle.files["assets/logo.png"] == b"\x89PNG\r\n\x1a\n\x00\xff"
|
||||
assert "examples/not-installed.md" not in bundle.files
|
||||
assert bundle.metadata["source_url"] == url
|
||||
|
||||
|
||||
def test_url_source_rejects_traversal_reference(monkeypatch):
|
||||
source = UrlSource()
|
||||
skill = "---\nname: bad\ndescription: bad\n---\n[bad](references/../../secret.txt)\n"
|
||||
monkeypatch.setattr(source, "_fetch_text", lambda _url: skill)
|
||||
|
||||
assert source.fetch("https://example.com/bad/SKILL.md") is None
|
||||
|
||||
|
||||
def test_github_source_rejects_symlink_in_referenced_directory(monkeypatch):
|
||||
source = GitHubSource(GitHubAuth())
|
||||
monkeypatch.setattr(source, "_fetch_file_content", lambda _repo, path: SKILL_MD if path.endswith("SKILL.md") else "x")
|
||||
source._tree_cache["owner/repo"] = (
|
||||
"main",
|
||||
[
|
||||
{"path": "skill/SKILL.md", "type": "blob", "mode": "100644"},
|
||||
{"path": "skill/references/guide.md", "type": "blob", "mode": "120000"},
|
||||
],
|
||||
)
|
||||
|
||||
assert source.fetch("owner/repo/skill") is None
|
||||
|
||||
|
||||
def test_github_source_fetches_only_exact_references_and_records_tree_revision(monkeypatch):
|
||||
source = GitHubSource(GitHubAuth())
|
||||
skill = "---\nname: demo\ndescription: demo\n---\n[guide](references/guide.md)\n"
|
||||
fetched = []
|
||||
monkeypatch.setattr(
|
||||
source,
|
||||
"_fetch_file_content",
|
||||
lambda _repo, path: skill if path.endswith("SKILL.md") else None,
|
||||
)
|
||||
|
||||
def _fetch_bytes(_repo, path):
|
||||
fetched.append(path)
|
||||
return b"guide"
|
||||
|
||||
monkeypatch.setattr(source, "_fetch_file_bytes", _fetch_bytes, raising=False)
|
||||
source._tree_cache["owner/repo"] = (
|
||||
"develop",
|
||||
[
|
||||
{"path": "skill/SKILL.md", "type": "blob", "mode": "100644"},
|
||||
{"path": "skill/references/guide.md", "type": "blob", "mode": "100644"},
|
||||
{"path": "skill/references/unreferenced.md", "type": "blob", "mode": "100644"},
|
||||
],
|
||||
)
|
||||
source._tree_revisions = {"owner/repo": "deadbeef"}
|
||||
|
||||
bundle = source.fetch("owner/repo/skill")
|
||||
|
||||
assert bundle is not None
|
||||
assert fetched == ["skill/references/guide.md"]
|
||||
assert bundle.files["references/guide.md"] == b"guide"
|
||||
assert bundle.metadata["source_url"] == "https://github.com/owner/repo/tree/deadbeef/skill"
|
||||
assert bundle.metadata["source_revision"] == "deadbeef"
|
||||
|
||||
|
||||
def test_scan_cache_records_full_provenance_and_hash_change_forces_rescan(tmp_path):
|
||||
skill = tmp_path / "skill"
|
||||
skill.mkdir()
|
||||
(skill / "SKILL.md").write_text("---\nname: skill\ndescription: test\n---\n# safe\n")
|
||||
cache = tmp_path / "scan-cache"
|
||||
|
||||
first, first_provenance = scan_skill_cached(
|
||||
skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache
|
||||
)
|
||||
second, second_provenance = scan_skill_cached(
|
||||
skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache
|
||||
)
|
||||
(skill / "SKILL.md").write_text("---\nname: skill\ndescription: changed\n---\n# safe\n")
|
||||
third, third_provenance = scan_skill_cached(
|
||||
skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache
|
||||
)
|
||||
|
||||
assert first.verdict == second.verdict == third.verdict == "safe"
|
||||
assert first_provenance["fresh"] is True
|
||||
assert second_provenance["fresh"] is False
|
||||
assert third_provenance["fresh"] is True
|
||||
assert first_provenance["bundle_hash"].startswith("sha256:")
|
||||
assert len(first_provenance["bundle_hash"].split(":", 1)[1]) == 64
|
||||
assert third_provenance["bundle_hash"] != first_provenance["bundle_hash"]
|
||||
assert first_provenance["scanner_version"] == SCANNER_VERSION
|
||||
assert first_provenance["source_url"] == "https://github.com/owner/repo"
|
||||
assert isinstance(first_provenance["findings"], list)
|
||||
assert isinstance(first_provenance["rules"], list)
|
||||
assert first_provenance["scanned_at"]
|
||||
|
||||
|
||||
def test_scan_cache_never_reuses_provenance_across_sources(tmp_path):
|
||||
skill = tmp_path / "skill"
|
||||
skill.mkdir()
|
||||
(skill / "SKILL.md").write_text("---\nname: skill\ndescription: test\n---\n")
|
||||
cache = tmp_path / "scan-cache"
|
||||
|
||||
_first, first = scan_skill_cached(
|
||||
skill, source="community", source_url="https://one.example/SKILL.md", cache_dir=cache
|
||||
)
|
||||
_second, second = scan_skill_cached(
|
||||
skill, source="community", source_url="https://two.example/SKILL.md", cache_dir=cache
|
||||
)
|
||||
|
||||
assert first["fresh"] is True
|
||||
assert second["fresh"] is True
|
||||
assert second["source_url"] == "https://two.example/SKILL.md"
|
||||
|
||||
|
||||
def test_lock_file_persists_scan_provenance(tmp_path):
|
||||
lock = HubLockFile(tmp_path / "lock.json")
|
||||
provenance = {
|
||||
"source_url": "https://example.com/SKILL.md",
|
||||
"bundle_hash": "sha256:" + "a" * 64,
|
||||
"scanner_version": SCANNER_VERSION,
|
||||
"findings": [],
|
||||
"rules": [],
|
||||
"scanned_at": "2026-07-09T00:00:00+00:00",
|
||||
"fresh": True,
|
||||
}
|
||||
lock.record_install(
|
||||
name="demo", source="url", identifier="https://example.com/SKILL.md",
|
||||
trust_level="community", scan_verdict="safe", skill_hash="sha256:legacy",
|
||||
install_path="demo", files=["SKILL.md"], scan_provenance=provenance,
|
||||
)
|
||||
|
||||
assert lock.get_installed("demo")["scan_provenance"] == provenance
|
||||
|
||||
|
||||
def test_real_temp_repo_and_home_install_e2e(served_repo, monkeypatch, tmp_path):
|
||||
from hermes_cli.skills_hub import do_install
|
||||
import tools.skills_hub as hub
|
||||
|
||||
_repo, url = served_repo
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
|
||||
monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
|
||||
monkeypatch.setattr(hub, "create_source_router", lambda auth=None: [UrlSource()])
|
||||
|
||||
sink = StringIO()
|
||||
do_install(url, console=Console(file=sink, force_terminal=False), skip_confirm=True)
|
||||
|
||||
installed = home / "skills" / "demo-bundle"
|
||||
assert (installed / "references" / "guide.md").read_text() == "safe guide\n"
|
||||
assert (installed / "templates" / "report.md").is_file()
|
||||
assert (installed / "scripts" / "run.py").is_file()
|
||||
assert (installed / "examples" / "endpoint-inventory.md").is_file()
|
||||
assert not (installed / "examples" / "not-installed.md").exists()
|
||||
assert (installed / "assets" / "logo.png").read_bytes() == b"\x89PNG\r\n\x1a\n\x00\xff"
|
||||
entry = json.loads((home / "skills" / ".hub" / "lock.json").read_text())["installed"]["demo-bundle"]
|
||||
assert entry["scan_provenance"]["source_url"] == url
|
||||
assert entry["scan_provenance"]["fresh"] is True
|
||||
assert "Scan provenance: fresh" in sink.getvalue()
|
||||
|
||||
|
||||
def test_bundled_optional_source_still_includes_support_files(tmp_path, monkeypatch):
|
||||
from tools.skills_hub import OptionalSkillSource
|
||||
|
||||
root = tmp_path / "optional-skills"
|
||||
skill = root / "category" / "official-demo"
|
||||
(skill / "references").mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text("---\nname: official-demo\ndescription: demo\n---\n")
|
||||
(skill / "references" / "all.md").write_text("all")
|
||||
source = OptionalSkillSource()
|
||||
source._optional_dir = root
|
||||
|
||||
bundle = source.fetch("official/category/official-demo")
|
||||
assert bundle is not None
|
||||
assert set(bundle.files) == {"SKILL.md", "references/all.md"}
|
||||
|
|
@ -264,6 +264,48 @@ def test_block_and_respond(capture):
|
|||
assert result[0] == "my_answer"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("event", ["secret.request", "sudo.request"])
|
||||
def test_sensitive_prompt_timeout_emits_expiry(capture, event):
|
||||
server, buf = capture
|
||||
|
||||
assert server._block(event, "s1", {}, timeout=0) == ""
|
||||
|
||||
messages = [json.loads(line) for line in buf.getvalue().splitlines()]
|
||||
request, expiry = [message["params"] for message in messages]
|
||||
assert request["type"] == event
|
||||
assert expiry["type"] == event.removesuffix(".request") + ".expire"
|
||||
assert expiry["session_id"] == "s1"
|
||||
assert expiry["payload"]["request_id"] == request["payload"]["request_id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "value_key"),
|
||||
[("secret.respond", "value"), ("sudo.respond", "password")],
|
||||
)
|
||||
def test_late_sensitive_prompt_response_is_idempotent(server, method, value_key):
|
||||
response = server.handle_request(
|
||||
{
|
||||
"id": "late-response",
|
||||
"method": method,
|
||||
"params": {"request_id": "expired-request", value_key: ""},
|
||||
}
|
||||
)
|
||||
|
||||
assert response["result"] == {"status": "expired"}
|
||||
|
||||
|
||||
def test_late_clarify_response_remains_protocol_error(server):
|
||||
response = server.handle_request(
|
||||
{
|
||||
"id": "late-clarify",
|
||||
"method": "clarify.respond",
|
||||
"params": {"request_id": "expired-request", "answer": ""},
|
||||
}
|
||||
)
|
||||
|
||||
assert response["error"]["code"] == 4009
|
||||
|
||||
|
||||
def test_clear_pending(server):
|
||||
ev = threading.Event()
|
||||
# _pending values are (sid, Event) tuples
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import os
|
|||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
|
|
@ -1383,12 +1384,36 @@ def _command_detection_variants(command: str):
|
|||
yield variant
|
||||
|
||||
|
||||
def _is_verification_artifact_cleanup(command: str) -> bool:
|
||||
"""Return whether *command* only removes one Hermes ad-hoc temp script."""
|
||||
try:
|
||||
argv = shlex.split(command, posix=True)
|
||||
except ValueError:
|
||||
return False
|
||||
if len(argv) != 3 or argv[0] != "rm" or argv[1] != "-f":
|
||||
return False
|
||||
|
||||
operand = argv[2]
|
||||
temp_dir = os.path.realpath(tempfile.gettempdir())
|
||||
basename = os.path.basename(operand)
|
||||
if operand != os.path.join(temp_dir, basename):
|
||||
return False
|
||||
|
||||
target = os.path.realpath(operand)
|
||||
if os.path.dirname(target) != temp_dir:
|
||||
return False
|
||||
return re.fullmatch(r"hermes-(?:verify|ad-hoc)-[A-Za-z0-9_.-]+", basename) is not None
|
||||
|
||||
|
||||
def detect_dangerous_command(command: str) -> tuple:
|
||||
"""Check if a command matches any dangerous patterns.
|
||||
|
||||
Returns:
|
||||
(is_dangerous, pattern_key, description) or (False, None, None)
|
||||
"""
|
||||
if _is_verification_artifact_cleanup(command):
|
||||
return (False, None, None)
|
||||
|
||||
for command_variant in _command_detection_variants(command):
|
||||
command_lower = command_variant.lower()
|
||||
for pattern_re, description in DANGEROUS_PATTERNS_COMPILED:
|
||||
|
|
|
|||
|
|
@ -958,7 +958,18 @@ class ShellFileOperations(FileOperations):
|
|||
return path
|
||||
|
||||
def _escape_shell_arg(self, arg: str) -> str:
|
||||
"""Escape a string for safe use in shell commands."""
|
||||
"""Escape a string for safe use in shell commands.
|
||||
|
||||
On Windows a native drive path (``C:\\Users\\x``) is first rewritten to
|
||||
the Git Bash / MSYS ``/c/Users/x`` form: bash eats the backslashes and
|
||||
MSYS otherwise mangles ``C:\\...`` (the "Directory \\drivers\\etc does not
|
||||
exist" class of failures). Reuses the env-layer translator so shell file
|
||||
ops and the terminal ``cd`` agree on the path form. No-op off Windows and
|
||||
for non-drive-qualified paths.
|
||||
"""
|
||||
from tools.environments.local import _windows_to_msys_path
|
||||
|
||||
arg = _windows_to_msys_path(arg)
|
||||
# Use single quotes and escape any single quotes in the string
|
||||
return "'" + arg.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
|
|
|||
|
|
@ -25,12 +25,16 @@ Usage:
|
|||
import re
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
SCANNER_VERSION = "skills-guard-v1"
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -87,6 +91,7 @@ class ScanResult:
|
|||
findings: List[Finding] = field(default_factory=list)
|
||||
scanned_at: str = ""
|
||||
summary: str = ""
|
||||
scan_provenance: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -683,6 +688,81 @@ def scan_skill(skill_path: Path, source: str = "community") -> ScanResult:
|
|||
)
|
||||
|
||||
|
||||
def _content_digest(skill_path: Path) -> str:
|
||||
"""Canonical SHA-256 over relative paths and exact file bytes."""
|
||||
h = hashlib.sha256()
|
||||
if skill_path.is_dir():
|
||||
for file_path in sorted(skill_path.rglob("*")):
|
||||
if file_path.is_file():
|
||||
rel = file_path.relative_to(skill_path).as_posix()
|
||||
h.update(rel.encode("utf-8") + b"\x00")
|
||||
h.update(file_path.read_bytes())
|
||||
else:
|
||||
h.update(skill_path.read_bytes())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def full_content_hash(skill_path: Path) -> str:
|
||||
"""Full canonical digest used to bind scanner attestations."""
|
||||
return f"sha256:{_content_digest(skill_path)}"
|
||||
|
||||
|
||||
def _finding_dict(finding: Finding) -> dict:
|
||||
return {key: getattr(finding, key) for key in (
|
||||
"pattern_id", "severity", "category", "file", "line", "match", "description"
|
||||
)}
|
||||
|
||||
|
||||
def scan_skill_cached(
|
||||
skill_path: Path,
|
||||
source: str = "community",
|
||||
*,
|
||||
source_url: str = "",
|
||||
cache_dir: Path | None = None,
|
||||
) -> Tuple[ScanResult, dict]:
|
||||
"""Return a scan plus attestation, caching only exact current content."""
|
||||
bundle_hash = full_content_hash(skill_path)
|
||||
cache_root = cache_dir or skill_path.parent / ".scan-cache"
|
||||
source_identity = hashlib.sha256(f"{source}\0{source_url}".encode("utf-8")).hexdigest()[:16]
|
||||
cache_file = cache_root / f"{bundle_hash.split(':', 1)[1]}-{source_identity}.json"
|
||||
try:
|
||||
cached = json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
cached = None
|
||||
if (isinstance(cached, dict)
|
||||
and cached.get("bundle_hash") == bundle_hash
|
||||
and cached.get("scanner_version") == SCANNER_VERSION
|
||||
and cached.get("source") == source
|
||||
and cached.get("source_url") == source_url):
|
||||
result = ScanResult(
|
||||
skill_name=skill_path.name, source=source,
|
||||
trust_level=cached["trust_level"], verdict=cached["verdict"],
|
||||
findings=[Finding(**item) for item in cached.get("findings", [])],
|
||||
scanned_at=cached["scanned_at"], summary=cached.get("summary", ""),
|
||||
)
|
||||
provenance = dict(cached)
|
||||
provenance["fresh"] = False
|
||||
result.scan_provenance = provenance
|
||||
return result, provenance
|
||||
|
||||
result = scan_skill(skill_path, source=source)
|
||||
findings = [_finding_dict(item) for item in result.findings]
|
||||
provenance = {
|
||||
"source": source, "source_url": source_url, "bundle_hash": bundle_hash,
|
||||
"scanner_version": SCANNER_VERSION, "verdict": result.verdict,
|
||||
"trust_level": result.trust_level, "findings": findings,
|
||||
"rules": sorted({item["pattern_id"] for item in findings}),
|
||||
"scanned_at": result.scanned_at, "summary": result.summary, "fresh": True,
|
||||
}
|
||||
try:
|
||||
cache_root.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
result.scan_provenance = provenance
|
||||
return result, provenance
|
||||
|
||||
|
||||
def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Determine whether a skill should be installed based on scan result and trust.
|
||||
|
|
@ -774,20 +854,7 @@ def content_hash(skill_path: Path) -> str:
|
|||
one on an in-memory bundle), so any change to the hash shape MUST
|
||||
land in both places at once.
|
||||
"""
|
||||
h = hashlib.sha256()
|
||||
if skill_path.is_dir():
|
||||
for f in sorted(skill_path.rglob("*")):
|
||||
if f.is_file():
|
||||
try:
|
||||
rel = f.relative_to(skill_path).as_posix()
|
||||
h.update(rel.encode("utf-8"))
|
||||
h.update(b"\x00")
|
||||
h.update(f.read_bytes())
|
||||
except OSError:
|
||||
continue
|
||||
elif skill_path.is_file():
|
||||
h.update(skill_path.read_bytes())
|
||||
return f"sha256:{h.hexdigest()[:16]}"
|
||||
return f"sha256:{_content_digest(skill_path)[:16]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ from hermes_constants import get_hermes_home
|
|||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
from agent.skill_utils import is_excluded_skill_path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import urljoin, urlparse, urlunparse
|
||||
from urllib.parse import unquote, urljoin, urlparse, urlsplit, urlunparse
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
|
@ -152,6 +152,46 @@ class SkillBundle:
|
|||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
_ALLOWED_SUPPORT_DIRS = frozenset({"references", "templates", "scripts", "assets", "examples"})
|
||||
_LOCAL_LINK_RE = re.compile(
|
||||
r"(?:\]\(|`|(?:^|[\s\"']))((?:references|templates|scripts|assets|examples)/[^\s)`\"'<>]+)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
_SUSPICIOUS_LOCAL_REF_RE = re.compile(
|
||||
r"(?:references|templates|scripts|assets|examples)/(?:[^\s)`\"'<>]*/)?\.\.(?:/|$)"
|
||||
)
|
||||
|
||||
|
||||
def _referenced_support_paths(skill_md: str) -> Optional[set[str]]:
|
||||
"""Extract safe referenced paths; return None on a traversal attempt."""
|
||||
normalized = skill_md.replace("\\", "/")
|
||||
if _SUSPICIOUS_LOCAL_REF_RE.search(normalized):
|
||||
return None
|
||||
paths: set[str] = set()
|
||||
for match in _LOCAL_LINK_RE.finditer(normalized):
|
||||
raw = unquote(urlsplit(match.group(1).rstrip(".,;:")).path)
|
||||
try:
|
||||
safe = _validate_bundle_rel_path(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if safe.split("/", 1)[0] in _ALLOWED_SUPPORT_DIRS:
|
||||
paths.add(safe)
|
||||
return paths
|
||||
|
||||
|
||||
def source_url_for_bundle(bundle: SkillBundle) -> str:
|
||||
"""Best available human-facing immutable-source provenance URL."""
|
||||
explicit = bundle.metadata.get("source_url") or bundle.metadata.get("url")
|
||||
if explicit:
|
||||
return str(explicit)
|
||||
if bundle.source == "github":
|
||||
parts = bundle.identifier.split("/", 2)
|
||||
if len(parts) >= 2:
|
||||
suffix = f"/tree/main/{parts[2]}" if len(parts) == 3 else ""
|
||||
return f"https://github.com/{parts[0]}/{parts[1]}{suffix}"
|
||||
return bundle.identifier
|
||||
|
||||
|
||||
def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bool) -> str:
|
||||
"""Normalize and validate bundle-controlled paths before touching disk."""
|
||||
if not isinstance(path_value, str):
|
||||
|
|
@ -535,6 +575,7 @@ class GitHubSource(SkillSource):
|
|||
# Per-instance cache: repo -> (default_branch, tree_entries)
|
||||
# Survives within a single search/install flow, avoiding redundant API calls.
|
||||
self._tree_cache: Dict[str, Tuple[str, List[dict]]] = {}
|
||||
self._tree_revisions: Dict[str, str] = {}
|
||||
# Per-repo cache of the optional skills.sh.json grouping sidecar,
|
||||
# mapping skill_name -> human-readable grouping title. ``None`` means
|
||||
# "fetched, no sidecar"; a missing key means "not fetched yet".
|
||||
|
|
@ -601,9 +642,40 @@ class GitHubSource(SkillSource):
|
|||
repo = f"{parts[0]}/{parts[1]}"
|
||||
skill_path = parts[2]
|
||||
|
||||
files = self._download_directory(repo, skill_path)
|
||||
if not files or "SKILL.md" not in files:
|
||||
skill_md = self._fetch_file_content(repo, f"{skill_path.rstrip('/')}/SKILL.md")
|
||||
if skill_md is None:
|
||||
return None
|
||||
referenced = _referenced_support_paths(skill_md)
|
||||
if referenced is None:
|
||||
return None
|
||||
|
||||
files: Dict[str, Union[str, bytes]] = {"SKILL.md": skill_md}
|
||||
tree = self._get_repo_tree(repo)
|
||||
if tree is not None:
|
||||
branch, entries = tree
|
||||
prefix = f"{skill_path.rstrip('/')}/"
|
||||
entries_by_path = {item.get("path", ""): item for item in entries}
|
||||
for rel_path in sorted(referenced):
|
||||
item_path = f"{prefix}{rel_path}"
|
||||
item = entries_by_path.get(item_path)
|
||||
if item is None:
|
||||
logger.warning("Referenced skill support file is missing: %s", item_path)
|
||||
return None
|
||||
if item.get("type") != "blob" or item.get("mode") == "120000":
|
||||
logger.warning("Rejected non-regular file in skill bundle: %s", item_path)
|
||||
return None
|
||||
content = self._fetch_file_bytes(repo, item_path)
|
||||
if content is None:
|
||||
return None
|
||||
files[rel_path] = content
|
||||
revision = self._tree_revisions.get(repo) or branch
|
||||
else:
|
||||
for rel_path in referenced:
|
||||
content = self._fetch_file_bytes(repo, f"{skill_path.rstrip('/')}/{rel_path}")
|
||||
if content is None:
|
||||
return None
|
||||
files[rel_path] = content
|
||||
revision = ""
|
||||
|
||||
skill_name = skill_path.rstrip("/").split("/")[-1]
|
||||
trust = self.trust_level_for(identifier)
|
||||
|
|
@ -614,6 +686,13 @@ class GitHubSource(SkillSource):
|
|||
source="github",
|
||||
identifier=identifier,
|
||||
trust_level=trust,
|
||||
metadata={
|
||||
"source_url": (
|
||||
f"https://github.com/{repo}/tree/{revision}/{skill_path}"
|
||||
if revision else f"https://github.com/{repo}/{skill_path}"
|
||||
),
|
||||
"source_revision": revision,
|
||||
},
|
||||
)
|
||||
|
||||
def inspect(self, identifier: str) -> Optional[SkillMeta]:
|
||||
|
|
@ -752,6 +831,9 @@ class GitHubSource(SkillSource):
|
|||
return None
|
||||
|
||||
entries = tree_data.get("tree", [])
|
||||
revision = tree_data.get("sha")
|
||||
if isinstance(revision, str) and revision:
|
||||
self._tree_revisions[repo] = revision
|
||||
self._tree_cache[repo] = (default_branch, entries)
|
||||
return (default_branch, entries)
|
||||
|
||||
|
|
@ -968,14 +1050,24 @@ class GitHubSource(SkillSource):
|
|||
return None
|
||||
|
||||
def _fetch_file_content(self, repo: str, path: str) -> Optional[str]:
|
||||
"""Fetch a single file's content from GitHub."""
|
||||
"""Fetch a single text file from GitHub."""
|
||||
content = self._fetch_file_bytes(repo, path)
|
||||
if content is None:
|
||||
return None
|
||||
try:
|
||||
return content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
def _fetch_file_bytes(self, repo: str, path: str) -> Optional[bytes]:
|
||||
"""Fetch exact file bytes from GitHub without text decoding."""
|
||||
url = f"https://api.github.com/repos/{repo}/contents/{path}"
|
||||
resp = self._github_get(
|
||||
url,
|
||||
headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"},
|
||||
)
|
||||
if resp is not None and resp.status_code == 200:
|
||||
return resp.text
|
||||
return resp.content
|
||||
return None
|
||||
|
||||
def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]:
|
||||
|
|
@ -1318,12 +1410,12 @@ class WellKnownSkillSource(SkillSource):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
class UrlSource(SkillSource):
|
||||
"""Fetch a single-file SKILL.md skill directly from an HTTP(S) URL.
|
||||
"""Fetch SKILL.md plus explicitly referenced, allowlisted support files.
|
||||
|
||||
The identifier IS the URL (e.g. ``https://example.com/path/SKILL.md``).
|
||||
Only single-file skills are supported — multi-file skills with
|
||||
``references/`` or ``scripts/`` subfolders need a manifest we can't
|
||||
discover from a bare URL.
|
||||
Bare URLs cannot safely enumerate a repository, so only exact references
|
||||
below references/templates/scripts/assets are fetched. Other repository
|
||||
files are never copied.
|
||||
|
||||
The skill name is read from the ``name:`` field in the SKILL.md YAML
|
||||
frontmatter (with a URL-slug fallback). Trust level is always
|
||||
|
|
@ -1402,6 +1494,19 @@ class UrlSource(SkillSource):
|
|||
|
||||
fm = GitHubSource._parse_frontmatter_quick(text)
|
||||
name = self._resolve_skill_name(fm, url)
|
||||
referenced = _referenced_support_paths(text)
|
||||
if referenced is None:
|
||||
return None
|
||||
files: Dict[str, Union[str, bytes]] = {"SKILL.md": text}
|
||||
base_url = url.rsplit("/", 1)[0] + "/"
|
||||
for rel_path in sorted(referenced):
|
||||
support_url = urljoin(base_url, rel_path)
|
||||
if urlparse(support_url).netloc != urlparse(url).netloc:
|
||||
return None
|
||||
content = self._fetch_bytes(support_url)
|
||||
if content is None:
|
||||
return None
|
||||
files[rel_path] = content
|
||||
|
||||
# When auto-resolution fails, return a bundle with an empty name and
|
||||
# ``awaiting_name=True`` in metadata. The install flow (``do_install``)
|
||||
|
|
@ -1418,11 +1523,11 @@ class UrlSource(SkillSource):
|
|||
|
||||
return SkillBundle(
|
||||
name=skill_name,
|
||||
files={"SKILL.md": text},
|
||||
files=files,
|
||||
source="url",
|
||||
identifier=url,
|
||||
trust_level="community",
|
||||
metadata={"url": url, "awaiting_name": not skill_name},
|
||||
metadata={"url": url, "source_url": url, "awaiting_name": not skill_name},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -1432,6 +1537,13 @@ class UrlSource(SkillSource):
|
|||
return resp.text
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _fetch_bytes(url: str) -> Optional[bytes]:
|
||||
resp = _guarded_http_get(url, timeout=20)
|
||||
if resp is not None and resp.status_code == 200:
|
||||
return resp.content
|
||||
return None
|
||||
|
||||
# Skill names must look like identifiers: lowercase letters/digits with
|
||||
# optional hyphens/underscores. Blocks dangerous (``../evil``) AND useless
|
||||
# (``SKILL``, ``README``, empty) candidates before they hit the disk.
|
||||
|
|
@ -3304,6 +3416,7 @@ class HubLockFile:
|
|||
install_path: str,
|
||||
files: List[str],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
scan_provenance: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
# Validate both the skill name and the install path SHAPE before
|
||||
# writing into lock.json. A poisoned lock entry is the precondition
|
||||
|
|
@ -3321,6 +3434,7 @@ class HubLockFile:
|
|||
"install_path": safe_install_path,
|
||||
"files": files,
|
||||
"metadata": metadata or {},
|
||||
"scan_provenance": scan_provenance or {},
|
||||
"installed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
|
@ -3461,6 +3575,7 @@ def install_from_quarantine(
|
|||
category: str,
|
||||
bundle: SkillBundle,
|
||||
scan_result: ScanResult,
|
||||
scan_provenance: Optional[Dict[str, Any]] = None,
|
||||
) -> Path:
|
||||
"""Move a scanned skill from quarantine into the skills directory."""
|
||||
safe_skill_name = _validate_skill_name(skill_name)
|
||||
|
|
@ -3529,6 +3644,7 @@ def install_from_quarantine(
|
|||
install_path=str(install_dir.relative_to(_skills_dir())),
|
||||
files=list(bundle.files.keys()),
|
||||
metadata=bundle.metadata,
|
||||
scan_provenance=scan_provenance or getattr(scan_result, "scan_provenance", None),
|
||||
)
|
||||
|
||||
append_audit_log(
|
||||
|
|
|
|||
|
|
@ -2038,15 +2038,26 @@ def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
|
|||
_pending[rid] = (sid, ev)
|
||||
payload["request_id"] = rid
|
||||
_pending_prompt_payloads[rid] = (event, dict(payload))
|
||||
answered = False
|
||||
answer = ""
|
||||
answer_present = False
|
||||
try:
|
||||
_emit(event, sid, payload)
|
||||
ev.wait(timeout=timeout)
|
||||
answered = ev.wait(timeout=timeout)
|
||||
finally:
|
||||
with _prompt_lock:
|
||||
_pending.pop(rid, None)
|
||||
_pending_prompt_payloads.pop(rid, None)
|
||||
with _prompt_lock:
|
||||
return _answers.pop(rid, "")
|
||||
answer_present = rid in _answers
|
||||
answer = _answers.pop(rid, "")
|
||||
|
||||
if not answered and not answer_present and event in {"secret.request", "sudo.request"}:
|
||||
_emit(
|
||||
f"{event.removesuffix('.request')}.expire",
|
||||
sid,
|
||||
{"request_id": rid},
|
||||
)
|
||||
return answer
|
||||
|
||||
|
||||
def _clear_pending(sid: str | None = None) -> None:
|
||||
|
|
@ -10166,11 +10177,13 @@ def _(rid, params: dict) -> dict:
|
|||
# ── Methods: respond ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _respond(rid, params, key):
|
||||
def _respond(rid, params, key, *, allow_expired=False):
|
||||
r = params.get("request_id", "")
|
||||
with _prompt_lock:
|
||||
entry = _pending.get(r)
|
||||
if not entry:
|
||||
if allow_expired and r:
|
||||
return _ok(rid, {"status": "expired"})
|
||||
return _err(rid, 4009, f"no pending {key} request")
|
||||
_, ev = entry
|
||||
_answers[r] = params.get(key, "")
|
||||
|
|
@ -10191,12 +10204,12 @@ def _(rid, params: dict) -> dict:
|
|||
|
||||
@method("sudo.respond")
|
||||
def _(rid, params: dict) -> dict:
|
||||
return _respond(rid, params, "password")
|
||||
return _respond(rid, params, "password", allow_expired=True)
|
||||
|
||||
|
||||
@method("secret.respond")
|
||||
def _(rid, params: dict) -> dict:
|
||||
return _respond(rid, params, "value")
|
||||
return _respond(rid, params, "value", allow_expired=True)
|
||||
|
||||
|
||||
@method("approval.respond")
|
||||
|
|
|
|||
|
|
@ -287,7 +287,9 @@ Primary event types the client handles today:
|
|||
| `clarify.request` | `{ question, choices?, request_id }` |
|
||||
| `approval.request` | `{ command, description, allow_permanent? }` |
|
||||
| `sudo.request` | `{ request_id }` |
|
||||
| `sudo.expire` | `{ request_id }` clears a timed-out sudo prompt |
|
||||
| `secret.request` | `{ prompt, env_var, request_id }` |
|
||||
| `secret.expire` | `{ request_id }` clears a timed-out secret prompt |
|
||||
| `background.complete` | `{ task_id, text }` |
|
||||
| `billing.step_up.verification` | `{ verification_url, user_code }` |
|
||||
| `review.summary` | `{ text }` |
|
||||
|
|
@ -487,4 +489,4 @@ tui_gateway/
|
|||
server.py RPC handlers and session logic
|
||||
render.py optional rich/ANSI bridge
|
||||
slash_worker.py persistent HermesCLI subprocess for slash commands
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1307,6 +1307,24 @@ describe('createGatewayEventHandler', () => {
|
|||
expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false)
|
||||
})
|
||||
|
||||
it('clears only the matching sensitive prompt when the gateway expires it', () => {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
patchOverlayState({
|
||||
secret: { envVar: 'NEW_KEY', prompt: 'Enter new key', requestId: 'secret-new' },
|
||||
sudo: { requestId: 'sudo-1' }
|
||||
})
|
||||
|
||||
onEvent({ payload: { request_id: 'secret-old' }, type: 'secret.expire' } as any)
|
||||
expect(getOverlayState().secret?.requestId).toBe('secret-new')
|
||||
|
||||
onEvent({ payload: { request_id: 'secret-new' }, type: 'secret.expire' } as any)
|
||||
expect(getOverlayState().secret).toBeNull()
|
||||
|
||||
onEvent({ payload: { request_id: 'sudo-1' }, type: 'sudo.expire' } as any)
|
||||
expect(getOverlayState().sudo).toBeNull()
|
||||
})
|
||||
|
||||
// ── Credits notice (Strategy B) ──────────────────────────────────────
|
||||
describe('credits notice', () => {
|
||||
it('shows a notice immediately when idle (no turn in flight)', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import {
|
||||
applyVoiceRecordResponse,
|
||||
dismissSensitivePrompt,
|
||||
handleIdleHotkeyExit,
|
||||
shouldAllowIdleHotkeyExit,
|
||||
shouldFallThroughForScroll
|
||||
|
|
@ -112,3 +114,33 @@ describe('applyVoiceRecordResponse', () => {
|
|||
expect(setProcessing).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismissSensitivePrompt', () => {
|
||||
it('clears a sudo overlay before a stale cancel RPC resolves', async () => {
|
||||
resetOverlayState()
|
||||
patchOverlayState({ sudo: { requestId: 'sudo-1' } })
|
||||
const rpc = vi.fn().mockResolvedValue(null)
|
||||
const sys = vi.fn()
|
||||
|
||||
const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys)
|
||||
|
||||
expect(getOverlayState().sudo).toBeNull()
|
||||
expect(sys).toHaveBeenCalledWith('sudo cancelled')
|
||||
expect(rpc).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' })
|
||||
await pending
|
||||
})
|
||||
|
||||
it('clears a secret overlay before a stale cancel RPC resolves', async () => {
|
||||
resetOverlayState()
|
||||
patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'secret-1' } })
|
||||
const rpc = vi.fn().mockResolvedValue(null)
|
||||
const sys = vi.fn()
|
||||
|
||||
const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys)
|
||||
|
||||
expect(getOverlayState().secret).toBeNull()
|
||||
expect(sys).toHaveBeenCalledWith('secret entry cancelled')
|
||||
expect(rpc).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' })
|
||||
await pending
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -807,6 +807,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
return
|
||||
|
||||
case 'sudo.expire':
|
||||
patchOverlayState(prev => (prev.sudo?.requestId === ev.payload.request_id ? { ...prev, sudo: null } : prev))
|
||||
|
||||
return
|
||||
|
||||
case 'secret.expire':
|
||||
patchOverlayState(prev => (prev.secret?.requestId === ev.payload.request_id ? { ...prev, secret: null } : prev))
|
||||
|
||||
return
|
||||
|
||||
case 'background.complete':
|
||||
dropBgTask(ev.payload.task_id)
|
||||
sys(`[bg ${ev.payload.task_id}] ${ev.payload.text}`)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,13 @@ import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionW
|
|||
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
|
||||
|
||||
import { getInputSelection } from './inputSelectionStore.js'
|
||||
import type { InputHandlerActions, InputHandlerContext, InputHandlerResult } from './interfaces.js'
|
||||
import type {
|
||||
GatewayRpc,
|
||||
InputHandlerActions,
|
||||
InputHandlerContext,
|
||||
InputHandlerResult,
|
||||
OverlayState
|
||||
} from './interfaces.js'
|
||||
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { patchTurnState } from './turnStore.js'
|
||||
|
|
@ -97,6 +103,30 @@ export function applyVoiceRecordResponse(
|
|||
}
|
||||
}
|
||||
|
||||
export function dismissSensitivePrompt(
|
||||
overlay: Pick<OverlayState, 'secret' | 'sudo'>,
|
||||
rpc: GatewayRpc,
|
||||
sys: (text: string) => void
|
||||
) {
|
||||
if (overlay.sudo) {
|
||||
const requestId = overlay.sudo.requestId
|
||||
|
||||
patchOverlayState({ sudo: null })
|
||||
sys('sudo cancelled')
|
||||
|
||||
return rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: requestId })
|
||||
}
|
||||
|
||||
if (overlay.secret) {
|
||||
const requestId = overlay.secret.requestId
|
||||
|
||||
patchOverlayState({ secret: null })
|
||||
sys('secret entry cancelled')
|
||||
|
||||
return rpc<SecretRespondResponse>('secret.respond', { request_id: requestId, value: '' })
|
||||
}
|
||||
}
|
||||
|
||||
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
|
||||
const { actions: cActions, refs: cRefs, state: cState } = composer
|
||||
|
|
@ -149,16 +179,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
.then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' })))
|
||||
}
|
||||
|
||||
if (overlay.sudo) {
|
||||
return gateway
|
||||
.rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: overlay.sudo.requestId })
|
||||
.then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled')))
|
||||
}
|
||||
|
||||
if (overlay.secret) {
|
||||
return gateway
|
||||
.rpc<SecretRespondResponse>('secret.respond', { request_id: overlay.secret.requestId, value: '' })
|
||||
.then(r => r && (patchOverlayState({ secret: null }), actions.sys('secret entry cancelled')))
|
||||
if (overlay.sudo || overlay.secret) {
|
||||
return dismissSensitivePrompt(overlay, gateway.rpc, actions.sys)
|
||||
}
|
||||
|
||||
if (overlay.modelPicker) {
|
||||
|
|
@ -373,7 +395,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
return
|
||||
}
|
||||
|
||||
if (isCtrl(key, ch, 'c')) {
|
||||
if (isCtrl(key, ch, 'c') || (key.escape && (overlay.secret || overlay.sudo))) {
|
||||
cancelOverlayFromCtrlC()
|
||||
} else if (key.escape && overlay.sessions) {
|
||||
patchOverlayState({ sessions: false })
|
||||
|
|
|
|||
|
|
@ -911,7 +911,13 @@ export function useMainApp(gw: GatewayClient) {
|
|||
return
|
||||
}
|
||||
|
||||
return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => {
|
||||
const requestId = overlay.sudo.requestId
|
||||
|
||||
if (!pw) {
|
||||
patchOverlayState({ sudo: null })
|
||||
}
|
||||
|
||||
return respondWith('sudo.respond', { password: pw, request_id: requestId }, () => {
|
||||
patchOverlayState({ sudo: null })
|
||||
patchUiState({ status: 'running…' })
|
||||
})
|
||||
|
|
@ -925,7 +931,13 @@ export function useMainApp(gw: GatewayClient) {
|
|||
return
|
||||
}
|
||||
|
||||
return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => {
|
||||
const requestId = overlay.secret.requestId
|
||||
|
||||
if (!value) {
|
||||
patchOverlayState({ secret: null })
|
||||
}
|
||||
|
||||
return respondWith('secret.respond', { request_id: requestId, value }, () => {
|
||||
patchOverlayState({ secret: null })
|
||||
patchUiState({ status: 'running…' })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -698,6 +698,7 @@ export type GatewayEvent =
|
|||
}
|
||||
| { payload: { request_id: string }; session_id?: string; type: 'sudo.request' }
|
||||
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
|
||||
| { payload: { request_id: string }; session_id?: string; type: 'secret.expire' | 'sudo.expire' }
|
||||
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
|
||||
| { payload?: { text?: string }; session_id?: string; type: 'review.summary' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' }
|
||||
|
|
|
|||
|
|
@ -336,7 +336,11 @@ export default function ChannelsPage() {
|
|||
{editing && (
|
||||
<div
|
||||
ref={editModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
className={cn(
|
||||
"fixed inset-0 z-[100] flex min-h-dvh items-start justify-center overflow-y-auto bg-background/85 px-4",
|
||||
"pb-[calc(1rem+env(safe-area-inset-bottom))] pt-[calc(1rem+env(safe-area-inset-top))]",
|
||||
"sm:items-center sm:p-4",
|
||||
)}
|
||||
onClick={(e) => e.target === e.currentTarget && setEditing(null)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
|
@ -345,7 +349,7 @@ export default function ChannelsPage() {
|
|||
<div
|
||||
className={cn(
|
||||
themedBody,
|
||||
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh]",
|
||||
"relative flex max-h-[calc(100dvh-2rem)] w-full max-w-lg flex-col border border-border bg-card shadow-2xl sm:max-h-[90dvh]",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
|
|
@ -377,7 +381,7 @@ export default function ChannelsPage() {
|
|||
)}
|
||||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4 overflow-y-auto">
|
||||
<div className="grid gap-4 overflow-y-auto overscroll-contain p-4 sm:p-5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{editing.description}
|
||||
</p>
|
||||
|
|
@ -407,6 +411,7 @@ export default function ChannelsPage() {
|
|||
<Input
|
||||
id={`field-${field.key}`}
|
||||
type={field.is_password ? "password" : "text"}
|
||||
className="text-base leading-6 sm:text-xs sm:leading-4"
|
||||
placeholder={
|
||||
field.is_set
|
||||
? field.redacted_value || "•••••• (set — leave blank to keep)"
|
||||
|
|
@ -433,12 +438,17 @@ export default function ChannelsPage() {
|
|||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button ghost size="sm" onClick={() => setEditing(null)}>
|
||||
<div className="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setEditing(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="uppercase"
|
||||
className="w-full uppercase sm:w-auto"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ terminal.resize clipboard.paste image.attach
|
|||
|
||||
### Events streamed back
|
||||
|
||||
`message.delta`, `message.complete`, `tool.start`, `tool.progress`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `secret.request`, `gateway.ready`, plus session lifecycle and error events.
|
||||
`message.delta`, `message.complete`, `tool.start`, `tool.progress`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `sudo.expire`, `secret.request`, `secret.expire`, `gateway.ready`, plus session lifecycle and error events. Expiry events carry the original `{ request_id }`; external hosts should clear only the matching pending prompt.
|
||||
|
||||
### Pi-style RPC mapping
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ hermes skills install official/research/arxiv
|
|||
# Install from the hub in a chat session
|
||||
/skills install official/creative/songwriting-and-ai-music
|
||||
|
||||
# Install a single-file SKILL.md directly from any HTTP(S) URL
|
||||
# Install SKILL.md and its referenced support files from an HTTP(S) URL
|
||||
hermes skills install https://sharethis.chat/SKILL.md
|
||||
/skills install https://example.com/SKILL.md --name my-skill
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1065,7 +1065,7 @@ hermes skills inspect official/security/1password
|
|||
hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react
|
||||
hermes skills install official/migration/openclaw-migration
|
||||
hermes skills install skills-sh/anthropics/skills/pdf --force
|
||||
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md)
|
||||
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files)
|
||||
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
|
||||
hermes skills check
|
||||
hermes skills update
|
||||
|
|
@ -1083,7 +1083,7 @@ Notes:
|
|||
- `--source skills-sh` searches the public `skills.sh` directory.
|
||||
- `--source well-known` lets you point Hermes at a site exposing `/.well-known/skills/index.json`.
|
||||
- `--source browse-sh` searches [browse.sh](https://browse.sh)'s catalog of 200+ site-specific browser-automation skills. Identifiers look like `browse-sh/airbnb.com/search-listings-ddgioa`.
|
||||
- Passing an `http(s)://…/*.md` URL installs a single-file SKILL.md directly. When frontmatter has no `name:` and the URL slug isn't a valid identifier, an interactive terminal prompts for a name; non-interactive surfaces (`/skills install` inside the TUI, gateway platforms) require `--name <x>` instead.
|
||||
- Passing an `http(s)://…/*.md` URL installs `SKILL.md` plus explicitly referenced files under `references/`, `templates/`, `scripts/`, `assets/`, and `examples/`. When frontmatter has no `name:` and the URL slug isn't a valid identifier, an interactive terminal prompts for a name; non-interactive surfaces (`/skills install` inside the TUI, gateway platforms) require `--name <x>` instead.
|
||||
|
||||
## `hermes bundles`
|
||||
|
||||
|
|
|
|||
|
|
@ -276,9 +276,18 @@ Statuses are retained briefly after terminal states (`completed`, `failed`, or `
|
|||
|
||||
Server-Sent Events stream of the run's tool-call progress, token deltas, and lifecycle events. Designed for dashboards and thick clients that want to attach/detach without losing state.
|
||||
|
||||
Unconsumed event buffers expire after five minutes so a detached client cannot
|
||||
grow memory indefinitely. This expires transport state only: a run that is
|
||||
still executing remains visible to status polling, approval, stop control, and
|
||||
concurrency accounting until its executor work actually exits. A connected SSE
|
||||
subscriber continues draining normally.
|
||||
|
||||
### POST /v1/runs/\{run_id\}/stop
|
||||
|
||||
Interrupt a running agent turn. The endpoint returns immediately with `{"status": "stopping"}` while Hermes asks the active agent to stop at the next safe interruption point.
|
||||
The run stays tracked as `stopping` until the executor-backed work exits, then
|
||||
settles as `cancelled`; requesting stop never hides a worker that is still
|
||||
running.
|
||||
|
||||
### POST /v1/runs/\{run_id\}/approval
|
||||
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski
|
|||
│ │ ├── references/ # Additional docs
|
||||
│ │ ├── templates/ # Output formats
|
||||
│ │ ├── scripts/ # Helper scripts callable from the skill
|
||||
│ │ ├── examples/ # Referenced example outputs
|
||||
│ │ └── assets/ # Supplementary files
|
||||
│ └── vllm/
|
||||
│ └── SKILL.md
|
||||
|
|
@ -304,6 +305,13 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski
|
|||
└── .bundled_manifest # Tracks seeded bundled skills
|
||||
```
|
||||
|
||||
Third-party URL and GitHub installs include `SKILL.md` plus the exact local
|
||||
files it references under `references/`, `templates/`, `scripts/`, `assets/`,
|
||||
and `examples/`. Unreferenced repository files are not copied. Hermes scans the
|
||||
complete quarantined bundle and records the source URL, exact content hash,
|
||||
scanner version, findings, timestamp, and fresh-or-cached status in
|
||||
`skills/.hub/lock.json`.
|
||||
|
||||
## External Skill Directories
|
||||
|
||||
If you maintain skills outside of Hermes — for example, a shared `~/.agents/skills/` directory used by multiple AI tools — you can tell Hermes to scan those directories too.
|
||||
|
|
@ -517,7 +525,7 @@ hermes skills install openai/skills/k8s # Install with security scan
|
|||
hermes skills install official/security/1password
|
||||
hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force
|
||||
hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify
|
||||
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md)
|
||||
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files)
|
||||
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
|
||||
hermes skills list --source hub # List hub-installed skills
|
||||
hermes skills check # Check installed hub skills for upstream updates
|
||||
|
|
@ -538,7 +546,7 @@ hermes skills tap add myorg/skills-repo # Add a custom GitHub source
|
|||
| `official` | `official/security/1password` | Optional skills shipped with Hermes. |
|
||||
| `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | Searchable via `hermes skills search <query> --source skills-sh`. Hermes resolves alias-style skills when the skills.sh slug differs from the repo folder. |
|
||||
| `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | Skills served directly from `/.well-known/skills/index.json` on a website. Search using the site or docs URL. |
|
||||
| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to a single-file `SKILL.md`. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. |
|
||||
| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to `SKILL.md` plus explicitly referenced support files. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. |
|
||||
| `github` | `openai/skills/k8s` | Direct GitHub repo/path installs and custom taps. |
|
||||
| `clawhub`, `lobehub`, `browse-sh` | Source-specific identifiers | Community or marketplace integrations. |
|
||||
|
||||
|
|
@ -670,11 +678,11 @@ Identifiers use the form `browse-sh/<hostname>/<task-id>` and match the slug exp
|
|||
|
||||
#### 9. Direct URL (`url`)
|
||||
|
||||
Install a single-file `SKILL.md` directly from any HTTP(S) URL — useful when an author hosts a skill on their own site (no hub listing, no GitHub path to type). Hermes fetches the URL, parses the YAML frontmatter, security-scans it, and installs.
|
||||
Install `SKILL.md` directly from any HTTP(S) URL — useful when an author hosts a skill on their own site (no hub listing, no GitHub path to type). Hermes also fetches explicitly referenced files under `references/`, `templates/`, `scripts/`, `assets/`, and `examples/`, then scans and installs the complete bundle.
|
||||
|
||||
- Hermes source id: `url`
|
||||
- Identifier: the URL itself (no prefix needed)
|
||||
- Scope: **single-file `SKILL.md`** only. Multi-file skills with `references/` or `scripts/` need a manifest and should be published via one of the other sources above.
|
||||
- Scope: `SKILL.md` plus exact referenced support files in the allowlisted directories. Hermes does not enumerate or copy unrelated files from the host.
|
||||
|
||||
```bash
|
||||
hermes skills install https://sharethis.chat/SKILL.md
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ A form-based editor for `config.yaml`. All 150+ configuration fields are auto-di
|
|||
- **agent** — max iterations, gateway timeout, service tier
|
||||
- **delegation** — subagent limits, reasoning effort
|
||||
- **memory** — provider selection, context injection settings
|
||||
- **approvals** — dangerous command approval mode (ask/yolo/deny)
|
||||
- **approvals** — dangerous command approval mode (smart/manual/off)
|
||||
- And more — every section of config.yaml has corresponding form fields
|
||||
|
||||
Fields with known valid values (terminal backend, skin, approval mode, etc.) render as dropdowns. Booleans render as toggles. Everything else is a text input.
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ hermes skills install official/research/arxiv
|
|||
# 在聊天会话中从 Hub 安装
|
||||
/skills install official/creative/songwriting-and-ai-music
|
||||
|
||||
# 直接从任意 HTTP(S) URL 安装单文件 SKILL.md
|
||||
# 从 HTTP(S) URL 安装 SKILL.md 及其引用的支持文件
|
||||
hermes skills install https://sharethis.chat/SKILL.md
|
||||
/skills install https://example.com/SKILL.md --name my-skill
|
||||
```
|
||||
|
|
|
|||
|
|
@ -820,7 +820,7 @@ hermes skills inspect official/security/1password
|
|||
hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react
|
||||
hermes skills install official/migration/openclaw-migration
|
||||
hermes skills install skills-sh/anthropics/skills/pdf --force
|
||||
hermes skills install https://sharethis.chat/SKILL.md # 直接 URL(单文件 SKILL.md)
|
||||
hermes skills install https://sharethis.chat/SKILL.md # 直接 URL(含引用的支持文件)
|
||||
hermes skills install https://example.com/SKILL.md --name my-skill # frontmatter 无名称时覆盖名称
|
||||
hermes skills check
|
||||
hermes skills update
|
||||
|
|
@ -835,7 +835,7 @@ hermes skills reset google-workspace --restore --yes
|
|||
- `--source skills-sh` 搜索公共 `skills.sh` 目录。
|
||||
- `--source well-known` 允许你将 Hermes 指向暴露 `/.well-known/skills/index.json` 的站点。
|
||||
- `--source browse-sh` 搜索 [browse.sh](https://browse.sh) 包含 200+ 站点特定浏览器自动化 skill 的目录。标识符形如 `browse-sh/airbnb.com/search-listings-ddgioa`。
|
||||
- 传入 `http(s)://…/*.md` URL 可直接安装单文件 SKILL.md。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时,交互式终端会提示输入名称;非交互式界面(TUI 内的 `/skills install`、gateway 平台)需要改用 `--name <x>`。
|
||||
- 传入 `http(s)://…/*.md` URL 可安装 `SKILL.md`,以及其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时,交互式终端会提示输入名称;非交互式界面(TUI 内的 `/skills install`、gateway 平台)需要改用 `--name <x>`。
|
||||
|
||||
## `hermes bundles`
|
||||
|
||||
|
|
|
|||
|
|
@ -270,9 +270,12 @@ Runs 接受简单的 `input` 字符串,以及可选的 `session_id`、`instruc
|
|||
|
||||
run 的工具调用进度、token 增量和生命周期事件的 Server-Sent Events 流。专为需要附加/分离而不丢失状态的仪表板和厚客户端设计。
|
||||
|
||||
未消费的事件缓冲区会在五分钟后过期,避免已断开的客户端导致内存无限增长。这里只会过期传输状态:仍在执行的 run 会继续保留在状态轮询、审批、停止控制和并发计数中,直到其 executor 工作真正退出。已连接的 SSE 订阅者会继续正常消费事件。
|
||||
|
||||
### POST /v1/runs/\{run_id\}/stop
|
||||
|
||||
中断正在运行的 agent 轮次。端点立即返回 `{"status": "stopping"}`,同时 Hermes 要求活跃 agent 在下一个安全中断点停止。
|
||||
run 会保持 `stopping` 并继续被跟踪,直到 executor 支持的工作退出,然后进入 `cancelled`;停止请求不会隐藏仍在运行的 worker。
|
||||
|
||||
## Jobs API(后台计划任务)
|
||||
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ metadata:
|
|||
│ │ ├── references/ # Additional docs
|
||||
│ │ ├── templates/ # Output formats
|
||||
│ │ ├── scripts/ # Helper scripts callable from the skill
|
||||
│ │ ├── examples/ # Referenced example outputs
|
||||
│ │ └── assets/ # Supplementary files
|
||||
│ └── vllm/
|
||||
│ └── SKILL.md
|
||||
|
|
@ -218,6 +219,8 @@ metadata:
|
|||
└── .bundled_manifest # Tracks seeded bundled skills
|
||||
```
|
||||
|
||||
通过第三方 URL 或 GitHub 安装时,Hermes 会安装 `SKILL.md`,以及其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件。未引用的仓库文件不会被复制。Hermes 会扫描完整的隔离捆绑包,并在 `skills/.hub/lock.json` 中记录来源 URL、精确内容哈希、扫描器版本、发现项、时间戳,以及本次结果是新扫描还是缓存复用。
|
||||
|
||||
## 外部 Skill 目录
|
||||
|
||||
如果你在 Hermes 之外维护 skills——例如,供多个 AI 工具使用的共享 `~/.agents/skills/` 目录——你可以告诉 Hermes 也扫描这些目录。
|
||||
|
|
@ -388,7 +391,7 @@ hermes skills install openai/skills/k8s # Install with security scan
|
|||
hermes skills install official/security/1password
|
||||
hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force
|
||||
hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify
|
||||
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md)
|
||||
hermes skills install https://sharethis.chat/SKILL.md # 直接 URL(含引用的支持文件)
|
||||
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
|
||||
hermes skills list --source hub # List hub-installed skills
|
||||
hermes skills check # Check installed hub skills for upstream updates
|
||||
|
|
@ -409,7 +412,7 @@ hermes skills tap add myorg/skills-repo # Add a custom GitHub source
|
|||
| `official` | `official/security/1password` | Hermes 随附的可选 skills。 |
|
||||
| `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | 可通过 `hermes skills search <query> --source skills-sh` 搜索。当 skills.sh slug 与仓库文件夹不同时,Hermes 会解析别名式 skills。 |
|
||||
| `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | 直接从网站的 `/.well-known/skills/index.json` 提供的 skills。使用站点或文档 URL 搜索。 |
|
||||
| `url` | `https://sharethis.chat/SKILL.md` | 指向单文件 `SKILL.md` 的直接 HTTP(S) URL。名称解析顺序:frontmatter → URL slug → 交互式提示 → `--name` 标志。 |
|
||||
| `url` | `https://sharethis.chat/SKILL.md` | 指向 `SKILL.md` 及其明确引用的支持文件的直接 HTTP(S) URL。名称解析顺序:frontmatter → URL slug → 交互式提示 → `--name` 标志。 |
|
||||
| `github` | `openai/skills/k8s` | 直接从 GitHub 仓库/路径安装以及基于 GitHub 的自定义 tap。 |
|
||||
| `clawhub`、`lobehub`、`browse-sh`、`claude-marketplace` | 来源特定标识符 | 社区或市场集成。 |
|
||||
|
||||
|
|
@ -523,11 +526,11 @@ hermes skills install browse-sh/airbnb.com/search-listings-ddgioa
|
|||
|
||||
#### 9. 直接 URL(`url`)
|
||||
|
||||
直接从任何 HTTP(S) URL 安装单文件 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用(无 hub 列表,无需输入 GitHub 路径)。Hermes 获取 URL,解析 YAML frontmatter,进行安全扫描并安装。
|
||||
直接从任何 HTTP(S) URL 安装 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用(无 hub 列表,无需输入 GitHub 路径)。Hermes 还会获取其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件,然后扫描并安装完整捆绑包。
|
||||
|
||||
- Hermes 来源 id:`url`
|
||||
- 标识符:URL 本身(无需前缀)
|
||||
- 范围:**仅限单文件 `SKILL.md`**。包含 `references/` 或 `scripts/` 的多文件 skills 需要清单,应通过上述其他来源之一发布。
|
||||
- 范围:`SKILL.md` 加上允许目录中明确引用的支持文件。Hermes 不会枚举或复制托管站点上的其他文件。
|
||||
|
||||
```bash
|
||||
hermes skills install https://sharethis.chat/SKILL.md
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ Chat 标签页是每次 `hermes dashboard` 启动的一部分——内嵌的浏
|
|||
- **agent** — 最大迭代次数、gateway 超时、服务层级
|
||||
- **delegation** — 子 agent 限制、推理力度
|
||||
- **memory** — 提供商选择、上下文注入设置
|
||||
- **approvals** — 危险命令审批模式(ask/yolo/deny)
|
||||
- **approvals** — 危险命令审批模式(smart/manual/off)
|
||||
- 更多——config.yaml 的每个部分都有对应的表单字段
|
||||
|
||||
具有已知有效值的字段(terminal 后端、皮肤、审批模式等)渲染为下拉菜单。布尔值渲染为开关。其余均为文本输入框。
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue