Merge branch 'main' into bb/gui

This commit is contained in:
emozilla 2026-05-16 00:13:51 -04:00
commit 6d3ed6b20d
13 changed files with 512 additions and 38 deletions

View file

@ -1272,12 +1272,40 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[
def _resolve_xai_oauth_for_aux() -> Optional[Tuple[str, str]]:
"""Resolve a fresh xAI OAuth (api_key, base_url) for auxiliary clients.
Routes through ``hermes_cli.auth``'s runtime resolver so the auto-refresh
path is shared with the main agent, instead of relying on whatever raw
tokens happen to be sitting in auth.json or the credential pool. Returns
``None`` if the user is not authenticated with xAI Grok OAuth (so
``_resolve_auto`` Step 1 falls through to the next provider in the chain).
Prefer the credential pool, matching the main runtime/provider status
path. Some xAI OAuth logins live only as pool entries; falling straight
to the singleton auth-store resolver would make auxiliary tasks such as
compression report "no provider configured" even though ``hermes auth
status`` shows xAI OAuth as logged in.
Falls back to ``hermes_cli.auth``'s singleton runtime resolver for older
auth-store-only logins. Returns ``None`` if the user is not authenticated
with xAI Grok OAuth.
"""
try:
from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
pool = load_pool("xai-oauth")
if pool and pool.has_credentials():
entry = pool.select()
if entry is not None:
api_key = str(
getattr(entry, "runtime_api_key", None)
or getattr(entry, "access_token", "")
or ""
).strip()
base_url = str(
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/")
or getattr(entry, "runtime_base_url", None)
or getattr(entry, "base_url", None)
or DEFAULT_XAI_OAUTH_BASE_URL
).strip().rstrip("/")
if api_key and base_url:
return api_key, base_url
except Exception as exc:
logger.debug("Auxiliary xAI OAuth pool credential resolution failed: %s", exc)
try:
from hermes_cli.auth import resolve_xai_oauth_runtime_credentials

8
cli.py
View file

@ -11736,11 +11736,13 @@ class HermesCLI:
# Ensure tirith security scanner is available (downloads if needed).
# Warn the user if tirith is enabled in config but not available,
# so they know command security scanning is degraded.
# so they know command security scanning is degraded. Suppressed
# on platforms where tirith ships no binary (Windows etc.) — the
# user can't act on it and pattern-matching guards still run.
try:
from tools.tirith_security import ensure_installed
from tools.tirith_security import ensure_installed, is_platform_supported
tirith_path = ensure_installed(log_failures=False)
if tirith_path is None:
if tirith_path is None and is_platform_supported():
security_cfg = self.config.get("security", {}) or {}
tirith_enabled = security_cfg.get("tirith_enabled", True)
if tirith_enabled:

View file

@ -3237,11 +3237,20 @@ class AIAgent:
except Exception:
_aux_cfg_provider = ""
if client is None or not aux_model:
msg = (
"⚠ No auxiliary LLM provider configured — context "
"compression will drop middle turns without a summary. "
"Run `hermes setup` or set OPENROUTER_API_KEY."
)
if _aux_cfg_provider and _aux_cfg_provider != "auto":
msg = (
"⚠ Configured auxiliary compression provider "
f"'{_aux_cfg_provider}' is unavailable — context "
"compression will drop middle turns without a summary. "
"Check auxiliary.compression in config.yaml and "
"reauthenticate that provider."
)
else:
msg = (
"⚠ No auxiliary LLM provider configured — context "
"compression will drop middle turns without a summary. "
"Run `hermes setup` or set OPENROUTER_API_KEY."
)
self._compression_warning = msg
self._emit_status(msg)
logger.warning(

View file

@ -26,6 +26,7 @@ from agent.auxiliary_client import (
_normalize_aux_provider,
_try_payment_fallback,
_resolve_auto,
_resolve_xai_oauth_for_aux,
_CodexCompletionsAdapter,
)
@ -221,6 +222,77 @@ class TestReadCodexAccessToken:
assert result == "plain-token-no-jwt"
class TestResolveXaiOAuthForAux:
def test_uses_pool_backed_credentials_without_singleton(self, tmp_path, monkeypatch):
"""Auxiliary xAI OAuth must see pool-only credentials.
``hermes auth status`` already reports these as logged in; compression
should not fall through to "no auxiliary provider configured" just
because the singleton auth-store entry is absent.
"""
from agent.credential_pool import AUTH_TYPE_OAUTH, PooledCredential, load_pool
from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
monkeypatch.delenv("XAI_BASE_URL", raising=False)
pool = load_pool("xai-oauth")
pool.add_entry(PooledCredential(
provider="xai-oauth",
id="xai123",
label="pool-only",
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source="manual:xai_pkce",
access_token="pool-access-token",
refresh_token="pool-refresh-token",
base_url=DEFAULT_XAI_OAUTH_BASE_URL,
))
assert _resolve_xai_oauth_for_aux() == (
"pool-access-token",
DEFAULT_XAI_OAUTH_BASE_URL,
)
def test_pool_backed_credentials_honor_base_url_env_override(self, tmp_path, monkeypatch):
from agent.credential_pool import AUTH_TYPE_OAUTH, PooledCredential, load_pool
from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("HERMES_XAI_BASE_URL", "https://example.x.ai/v1/")
pool = load_pool("xai-oauth")
pool.add_entry(PooledCredential(
provider="xai-oauth",
id="xai456",
label="pool-only",
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source="manual:xai_pkce",
access_token="pool-access-token",
refresh_token="pool-refresh-token",
base_url=DEFAULT_XAI_OAUTH_BASE_URL,
))
assert _resolve_xai_oauth_for_aux() == (
"pool-access-token",
"https://example.x.ai/v1",
)
class TestAnthropicOAuthFlag:
"""Test that OAuth tokens get is_oauth=True in auxiliary Anthropic client."""

View file

@ -333,6 +333,103 @@ class TestEnsureInstalled:
_tirith_mod._resolved_path = None
# ---------------------------------------------------------------------------
# Unsupported platform (Windows etc.) — silent fast-path everywhere
# ---------------------------------------------------------------------------
class TestUnsupportedPlatform:
"""When _detect_target() returns None (no tirith binary for this OS+arch),
the entire subsystem must stay silent: no PATH probes, no download thread,
no disk failure marker, no spawn attempts, no CLI banner. Pattern-matching
guards still cover the gap; tirith content scanning is just absent."""
def test_is_platform_supported_true_on_linux_x86_64(self):
with patch("tools.tirith_security.platform.system", return_value="Linux"), \
patch("tools.tirith_security.platform.machine", return_value="x86_64"):
assert _tirith_mod.is_platform_supported() is True
def test_is_platform_supported_true_on_darwin_arm64(self):
with patch("tools.tirith_security.platform.system", return_value="Darwin"), \
patch("tools.tirith_security.platform.machine", return_value="arm64"):
assert _tirith_mod.is_platform_supported() is True
def test_is_platform_supported_false_on_windows(self):
with patch("tools.tirith_security.platform.system", return_value="Windows"), \
patch("tools.tirith_security.platform.machine", return_value="AMD64"):
assert _tirith_mod.is_platform_supported() is False
def test_is_platform_supported_false_on_unknown_arch(self):
with patch("tools.tirith_security.platform.system", return_value="Linux"), \
patch("tools.tirith_security.platform.machine", return_value="riscv64"):
assert _tirith_mod.is_platform_supported() is False
@patch("tools.tirith_security._load_security_config")
def test_ensure_installed_unsupported_returns_none_no_thread(self, mock_cfg):
"""Windows: don't start a background install thread, don't write a
failure marker just cache the verdict and return None."""
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
"tirith_timeout": 5, "tirith_fail_open": True}
_tirith_mod._resolved_path = None
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
patch("tools.tirith_security.threading.Thread") as MockThread, \
patch("tools.tirith_security._mark_install_failed") as mock_mark, \
patch("tools.tirith_security.shutil.which") as mock_which:
result = ensure_installed()
assert result is None
MockThread.assert_not_called()
mock_mark.assert_not_called()
mock_which.assert_not_called()
assert _tirith_mod._resolved_path is _tirith_mod._INSTALL_FAILED
assert _tirith_mod._install_failure_reason == "unsupported_platform"
@patch("tools.tirith_security._load_security_config")
def test_check_command_security_unsupported_allows_silently(self, mock_cfg):
"""Windows: skip the resolver and spawn entirely — return allow with
an empty summary so callers can't accidentally surface 'tirith
unavailable' messaging to the user."""
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
"tirith_timeout": 5, "tirith_fail_open": True}
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
patch("tools.tirith_security.subprocess.run") as mock_run, \
patch("tools.tirith_security._resolve_tirith_path") as mock_resolve:
result = check_command_security("rm -rf /")
assert result == {"action": "allow", "findings": [], "summary": ""}
mock_run.assert_not_called()
mock_resolve.assert_not_called()
@patch("tools.tirith_security._load_security_config")
def test_resolve_path_unsupported_caches_failure_without_probing(self, mock_cfg):
"""The per-command resolver must also short-circuit on Windows so
long-running gateways don't churn through `shutil.which` and disk
I/O for every scanned command."""
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
"tirith_timeout": 5, "tirith_fail_open": True}
_tirith_mod._resolved_path = None
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
patch("tools.tirith_security.shutil.which") as mock_which:
result = _tirith_mod._resolve_tirith_path("tirith")
assert result == "tirith"
mock_which.assert_not_called()
assert _tirith_mod._resolved_path is _tirith_mod._INSTALL_FAILED
assert _tirith_mod._install_failure_reason == "unsupported_platform"
@patch("tools.tirith_security._load_security_config")
def test_explicit_path_still_honored_on_unsupported_platform(self, mock_cfg):
"""If a user explicitly configured a tirith_path (e.g. they built it
themselves under WSL), the unsupported-platform short-circuit must
NOT override that explicit config wins."""
mock_cfg.return_value = {"tirith_enabled": True,
"tirith_path": "/opt/custom/tirith",
"tirith_timeout": 5, "tirith_fail_open": True}
_tirith_mod._resolved_path = None
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
patch("os.path.isfile", return_value=True), \
patch("os.access", return_value=True):
result = _tirith_mod._resolve_tirith_path("/opt/custom/tirith")
assert result == "/opt/custom/tirith"
assert _tirith_mod._resolved_path == "/opt/custom/tirith"
# ---------------------------------------------------------------------------
# Failed download caches the miss (Finding #1)
# ---------------------------------------------------------------------------

View file

@ -214,7 +214,12 @@ def _hermes_bin_dir() -> str:
def _detect_target() -> str | None:
"""Return the Rust target triple for the current platform, or None."""
"""Return the Rust target triple for the current platform, or None.
Windows is intentionally unsupported tirith does not ship a Windows
build. Callers should treat `None` as "this platform will never have
tirith" and silently fall back to pattern-matching guards.
"""
system = platform.system()
machine = platform.machine().lower()
@ -236,6 +241,16 @@ def _detect_target() -> str | None:
return f"{arch}-{plat}"
def is_platform_supported() -> bool:
"""True when tirith ships a prebuilt binary for this OS+arch.
Used by callers (CLI banner, etc.) to distinguish "tirith failed to
install" from "tirith was never going to install here" — the latter
is silent because there is nothing the user can do about it.
"""
return _detect_target() is not None
def _download_file(url: str, dest: str, timeout: int = 10):
"""Download a URL to a local file."""
req = urllib.request.Request(url)
@ -448,6 +463,15 @@ def _resolve_tirith_path(configured_path: str) -> str:
explicit = _is_explicit_path(configured_path)
install_failed = _resolved_path is _INSTALL_FAILED
# Platform has no tirith build (Windows etc.). Cache the verdict and
# return the unexpanded configured path — the spawn loop will fail-open
# via the dedupe'd OSError handler, but only after the first call; on
# subsequent calls the fast-path above short-circuits before spawning.
if not explicit and not is_platform_supported():
_resolved_path = _INSTALL_FAILED
_install_failure_reason = "unsupported_platform"
return expanded
# Explicit path: check it and stop. Never auto-download a replacement.
if explicit:
if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
@ -574,6 +598,14 @@ def ensure_installed(*, log_failures: bool = True):
return path
return None
# Platform has no tirith build (e.g. Windows) — don't probe PATH,
# don't start a download thread, don't write a disk failure marker.
# Pattern-matching guards still run; this path stays silent.
if not is_platform_supported():
_resolved_path = _INSTALL_FAILED
_install_failure_reason = "unsupported_platform"
return None
configured_path = cfg["tirith_path"]
explicit = _is_explicit_path(configured_path)
expanded = os.path.expanduser(configured_path)
@ -659,6 +691,12 @@ def check_command_security(command: str) -> dict:
if not cfg["tirith_enabled"]:
return {"action": "allow", "findings": [], "summary": ""}
# Unsupported platform (Windows etc.) — tirith has no binary here and
# never will. Skip the resolver entirely so we don't even try to spawn.
# Pattern-matching guards still run via the rest of approval.py.
if not is_platform_supported():
return {"action": "allow", "findings": [], "summary": ""}
tirith_path = _resolve_tirith_path(cfg["tirith_path"])
timeout = cfg["tirith_timeout"]
fail_open = cfg["tirith_fail_open"]

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { approvalAction } from '../components/prompts.js'
describe('approvalAction — pure key dispatch for ApprovalPrompt', () => {
it('maps Esc to deny — parity with global Ctrl+C cancellation', () => {
expect(approvalAction('', { escape: true }, 0)).toEqual({ kind: 'choose', choice: 'deny' })
expect(approvalAction('', { escape: true }, 2)).toEqual({ kind: 'choose', choice: 'deny' })
})
it('maps number keys 1..4 to once/session/always/deny in registration order', () => {
expect(approvalAction('1', {}, 0)).toEqual({ kind: 'choose', choice: 'once' })
expect(approvalAction('2', {}, 0)).toEqual({ kind: 'choose', choice: 'session' })
expect(approvalAction('3', {}, 0)).toEqual({ kind: 'choose', choice: 'always' })
expect(approvalAction('4', {}, 0)).toEqual({ kind: 'choose', choice: 'deny' })
})
it('ignores out-of-range numbers', () => {
expect(approvalAction('0', {}, 1)).toEqual({ kind: 'noop' })
expect(approvalAction('5', {}, 1)).toEqual({ kind: 'noop' })
expect(approvalAction('9', {}, 1)).toEqual({ kind: 'noop' })
})
it('confirms the current selection on Enter', () => {
expect(approvalAction('', { return: true }, 0)).toEqual({ kind: 'choose', choice: 'once' })
expect(approvalAction('', { return: true }, 3)).toEqual({ kind: 'choose', choice: 'deny' })
})
it('moves selection up/down within bounds', () => {
expect(approvalAction('', { upArrow: true }, 2)).toEqual({ kind: 'move', delta: -1 })
expect(approvalAction('', { downArrow: true }, 1)).toEqual({ kind: 'move', delta: 1 })
})
it('clamps selection movement at the edges', () => {
expect(approvalAction('', { upArrow: true }, 0)).toEqual({ kind: 'noop' })
expect(approvalAction('', { downArrow: true }, 3)).toEqual({ kind: 'noop' })
})
it('Esc beats numeric/return — denying is always the first interpretation', () => {
// If a terminal somehow delivers Esc + a digit in the same event, deny
// wins. Documents the precedence so a future refactor doesn't flip it.
expect(approvalAction('1', { escape: true }, 0)).toEqual({ kind: 'choose', choice: 'deny' })
expect(approvalAction('', { escape: true, return: true }, 1)).toEqual({ kind: 'choose', choice: 'deny' })
})
it('returns noop for unrelated keystrokes (printable letters etc.)', () => {
expect(approvalAction('a', {}, 0)).toEqual({ kind: 'noop' })
expect(approvalAction(' ', {}, 0)).toEqual({ kind: 'noop' })
})
})

View file

@ -1,6 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { applyVoiceRecordResponse } from '../app/useInputHandlers.js'
import { applyVoiceRecordResponse, shouldFallThroughForScroll } from '../app/useInputHandlers.js'
const baseKey = {
downArrow: false,
pageDown: false,
pageUp: false,
shift: false,
upArrow: false,
wheelDown: false,
wheelUp: false
}
describe('shouldFallThroughForScroll — keep transcript scrolling alive during prompt overlays', () => {
it('falls through for wheel scrolls', () => {
expect(shouldFallThroughForScroll({ ...baseKey, wheelUp: true })).toBe(true)
expect(shouldFallThroughForScroll({ ...baseKey, wheelDown: true })).toBe(true)
})
it('falls through for PageUp / PageDown', () => {
expect(shouldFallThroughForScroll({ ...baseKey, pageUp: true })).toBe(true)
expect(shouldFallThroughForScroll({ ...baseKey, pageDown: true })).toBe(true)
})
it('falls through for Shift+ArrowUp / Shift+ArrowDown', () => {
expect(shouldFallThroughForScroll({ ...baseKey, shift: true, upArrow: true })).toBe(true)
expect(shouldFallThroughForScroll({ ...baseKey, shift: true, downArrow: true })).toBe(true)
})
it('does NOT fall through for plain arrows — those drive in-prompt selection', () => {
expect(shouldFallThroughForScroll({ ...baseKey, upArrow: true })).toBe(false)
expect(shouldFallThroughForScroll({ ...baseKey, downArrow: true })).toBe(false)
})
it('does NOT fall through for plain Shift — without an arrow it is a no-op', () => {
expect(shouldFallThroughForScroll({ ...baseKey, shift: true })).toBe(false)
})
it('does NOT fall through for unrelated state (no scroll keys held)', () => {
expect(shouldFallThroughForScroll(baseKey)).toBe(false)
})
})
describe('applyVoiceRecordResponse', () => {
it('reverts optimistic REC state when the gateway reports voice busy', () => {

View file

@ -23,6 +23,42 @@ import { getUiState } from './uiStore.js'
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
/**
* Approval / clarify / confirm overlays mount their own `useInput` handlers
* for the in-prompt keys (arrows, numbers, Enter, sometimes Esc). The global
* input handler used to early-return for any other key while one of those
* overlays was up, which silently disabled transcript scrolling the user
* couldn't read context above the prompt that the prompt itself was asking
* about. Returns true when the key is a transcript-scroll input that should
* fall through to the global scroll handlers even while a prompt is active.
*
* Modifier-held wheel (precision mode) is included a user who wants to
* scroll a single line at a time during a prompt expects it to work.
*/
export function shouldFallThroughForScroll(key: {
downArrow: boolean
pageDown: boolean
pageUp: boolean
shift: boolean
upArrow: boolean
wheelDown: boolean
wheelUp: boolean
}): boolean {
if (key.wheelUp || key.wheelDown) {
return true
}
if (key.pageUp || key.pageDown) {
return true
}
if (key.shift && (key.upArrow || key.downArrow)) {
return true
}
return false
}
export function applyVoiceRecordResponse(
response: null | VoiceRecordResponse,
starting: boolean,
@ -224,7 +260,18 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
// handlers must receive keystrokes (arrow keys, numbers, Enter). Only
// intercept Ctrl+C here so the user can deny/dismiss — all other keys
// fall through to the component-level handlers.
if (overlay.approval || overlay.clarify || overlay.confirm) {
//
// Scroll inputs (wheel / PageUp / PageDown / Shift+↑↓) are special:
// they must reach the transcript scroll handlers below even with a
// prompt up. Long-thread context the prompt is asking about often
// lives above the visible viewport, and being unable to read it while
// answering felt like the prompt had locked the entire UI. Explicitly
// skip the prompt-overlay early-return for scroll keys so they fall
// through to the wheel / PageUp / Shift+arrow handlers below.
const promptOverlay = overlay.approval || overlay.clarify || overlay.confirm
const fallThroughForScroll = promptOverlay && shouldFallThroughForScroll(key)
if (promptOverlay && !fallThroughForScroll) {
if (isCtrl(key, ch, 'c')) {
cancelOverlayFromCtrlC()
}
@ -298,7 +345,13 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
patchOverlayState({ picker: false })
}
return
// When a prompt overlay is up and the user pressed a scroll key, fall
// through to the global scroll handlers below instead of returning.
// Otherwise nothing above this comment matched, and there's nothing
// useful to do for an arbitrary key while blocked.
if (!fallThroughForScroll) {
return
}
}
if (cState.completions.length && cState.input && cState.historyIdx === null && (key.upArrow || key.downArrow)) {

View file

@ -11,28 +11,65 @@ const OPTS = ['once', 'session', 'always', 'deny'] as const
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
const CMD_PREVIEW_LINES = 10
type ApprovalKey = {
downArrow?: boolean
escape?: boolean
return?: boolean
upArrow?: boolean
}
type ApprovalAction =
| { kind: 'choose'; choice: (typeof OPTS)[number] }
| { kind: 'move'; delta: -1 | 1 }
| { kind: 'noop' }
/**
* Pure key-dispatch for the approval prompt exported so the regression
* matrix (Esc, Ctrl+C-equivalent, number keys, Enter, ) is testable
* without mounting React + Ink + a fake stdin. The component just maps the
* action onto its own state setters.
*
* Esc and number keys both terminate the prompt; Esc maps to deny (parity
* with the global Ctrl+C handler that already calls cancelOverlayFromCtrlC
* for approvals). Numbers 1..OPTS.length pick the labelled choice. Enter
* confirms the current selection. / moves the selection within bounds.
*/
export function approvalAction(ch: string, key: ApprovalKey, sel: number): ApprovalAction {
if (key.escape) {
return { kind: 'choose', choice: 'deny' }
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= OPTS.length) {
return { kind: 'choose', choice: OPTS[n - 1]! }
}
if (key.return) {
return { kind: 'choose', choice: OPTS[sel]! }
}
if (key.upArrow && sel > 0) {
return { kind: 'move', delta: -1 }
}
if (key.downArrow && sel < OPTS.length - 1) {
return { kind: 'move', delta: 1 }
}
return { kind: 'noop' }
}
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
const [sel, setSel] = useState(0)
useInput((ch, key) => {
if (key.upArrow && sel > 0) {
setSel(s => s - 1)
}
const action = approvalAction(ch, key, sel)
if (key.downArrow && sel < OPTS.length - 1) {
setSel(s => s + 1)
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= OPTS.length) {
onChoice(OPTS[n - 1]!)
return
}
if (key.return) {
onChoice(OPTS[sel]!)
if (action.kind === 'choose') {
onChoice(action.choice)
} else if (action.kind === 'move') {
setSel(s => s + action.delta)
}
})
@ -71,7 +108,7 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
</Text>
))}
<Text color={t.color.muted}>/ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
<Text color={t.color.muted}>/ select · Enter confirm · 1-4 quick pick · Esc/Ctrl+C deny</Text>
</Box>
)
}

View file

@ -537,6 +537,8 @@ security:
When `tirith_fail_open` is `true` (default), commands proceed if tirith is not installed or times out. Set to `false` in high-security environments to block commands when tirith is unavailable.
Tirith ships prebuilt binaries for Linux (x86_64 / aarch64) and macOS (x86_64 / arm64). On platforms with no prebuilt binary (Windows, etc.), tirith is silently skipped — pattern-matching guards still run, and the CLI does not surface an "unavailable" banner. To use tirith on Windows, run Hermes under WSL.
Tirith's verdict integrates with the approval flow: safe commands pass through, while both suspicious and blocked commands trigger user approval with the full tirith findings (severity, title, description, safer alternatives). Users can approve or deny — the default choice is deny to keep unattended scenarios secure.
### Context File Injection Protection

View file

@ -602,7 +602,7 @@ def build_sidebar_items(entries: list[tuple[dict[str, Any], dict[str, Any]]]) ->
else:
optional[meta["category"]].append(meta)
def cat_section(bucket: dict[str, list[dict[str, Any]]]) -> list[dict]:
def cat_section(bucket: dict[str, list[dict[str, Any]]], source: str) -> list[dict]:
result = []
for category in sorted(bucket):
items = sorted(bucket[category], key=lambda m: m["slug"])
@ -610,6 +610,13 @@ def build_sidebar_items(entries: list[tuple[dict[str, Any], dict[str, Any]]]) ->
{
"type": "category",
"label": category,
# Docusaurus generates a translation key from the label by
# default (e.g. sidebar.docs.category.productivity). When
# the same category name appears under both Bundled and
# Optional, the duplicate keys break i18n extraction and
# fail the build. Scope each category by source to keep
# the keys unique.
"key": f"skills-{source}-{category}",
"collapsed": True,
"items": [sidebar_doc_id(m) for m in items],
}
@ -617,8 +624,8 @@ def build_sidebar_items(entries: list[tuple[dict[str, Any], dict[str, Any]]]) ->
return result
return {
"bundled_categories": cat_section(bundled),
"optional_categories": cat_section(optional),
"bundled_categories": cat_section(bundled, "bundled"),
"optional_categories": cat_section(optional, "optional"),
}
@ -633,6 +640,8 @@ def _render_sidebar_item(item: Any, indent: int) -> list[str]:
lines.append(f"{pad}{{")
lines.append(f"{pad} type: 'category',")
lines.append(f"{pad} label: '{item['label']}',")
if item.get("key"):
lines.append(f"{pad} key: '{item['key']}',")
if item.get("collapsed", True):
lines.append(f"{pad} collapsed: true,")
lines.append(f"{pad} items: [")

View file

@ -121,6 +121,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'apple',
key: 'skills-bundled-apple',
collapsed: true,
items: [
'user-guide/skills/bundled/apple/apple-apple-notes',
@ -133,6 +134,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'autonomous-ai-agents',
key: 'skills-bundled-autonomous-ai-agents',
collapsed: true,
items: [
'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code',
@ -144,6 +146,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'creative',
key: 'skills-bundled-creative',
collapsed: true,
items: [
'user-guide/skills/bundled/creative/creative-architecture-diagram',
@ -170,6 +173,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'data-science',
key: 'skills-bundled-data-science',
collapsed: true,
items: [
'user-guide/skills/bundled/data-science/data-science-jupyter-live-kernel',
@ -178,6 +182,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'devops',
key: 'skills-bundled-devops',
collapsed: true,
items: [
'user-guide/skills/bundled/devops/devops-kanban-orchestrator',
@ -188,6 +193,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'dogfood',
key: 'skills-bundled-dogfood',
collapsed: true,
items: [
'user-guide/skills/bundled/dogfood/dogfood-dogfood',
@ -196,6 +202,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'email',
key: 'skills-bundled-email',
collapsed: true,
items: [
'user-guide/skills/bundled/email/email-himalaya',
@ -204,6 +211,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'gaming',
key: 'skills-bundled-gaming',
collapsed: true,
items: [
'user-guide/skills/bundled/gaming/gaming-minecraft-modpack-server',
@ -213,6 +221,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'github',
key: 'skills-bundled-github',
collapsed: true,
items: [
'user-guide/skills/bundled/github/github-codebase-inspection',
@ -226,6 +235,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'mcp',
key: 'skills-bundled-mcp',
collapsed: true,
items: [
'user-guide/skills/bundled/mcp/mcp-native-mcp',
@ -234,6 +244,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'media',
key: 'skills-bundled-media',
collapsed: true,
items: [
'user-guide/skills/bundled/media/media-gif-search',
@ -246,6 +257,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'mlops',
key: 'skills-bundled-mlops',
collapsed: true,
items: [
'user-guide/skills/bundled/mlops/mlops-models-audiocraft',
@ -262,6 +274,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'note-taking',
key: 'skills-bundled-note-taking',
collapsed: true,
items: [
'user-guide/skills/bundled/note-taking/note-taking-obsidian',
@ -270,6 +283,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'productivity',
key: 'skills-bundled-productivity',
collapsed: true,
items: [
'user-guide/skills/bundled/productivity/productivity-airtable',
@ -286,6 +300,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'red-teaming',
key: 'skills-bundled-red-teaming',
collapsed: true,
items: [
'user-guide/skills/bundled/red-teaming/red-teaming-godmode',
@ -294,6 +309,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'research',
key: 'skills-bundled-research',
collapsed: true,
items: [
'user-guide/skills/bundled/research/research-arxiv',
@ -306,6 +322,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'smart-home',
key: 'skills-bundled-smart-home',
collapsed: true,
items: [
'user-guide/skills/bundled/smart-home/smart-home-openhue',
@ -314,6 +331,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'social-media',
key: 'skills-bundled-social-media',
collapsed: true,
items: [
'user-guide/skills/bundled/social-media/social-media-xurl',
@ -322,6 +340,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'software-development',
key: 'skills-bundled-software-development',
collapsed: true,
items: [
'user-guide/skills/bundled/software-development/software-development-debugging-hermes-tui-commands',
@ -340,6 +359,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'yuanbao',
key: 'skills-bundled-yuanbao',
collapsed: true,
items: [
'user-guide/skills/bundled/yuanbao/yuanbao-yuanbao',
@ -355,6 +375,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'autonomous-ai-agents',
key: 'skills-optional-autonomous-ai-agents',
collapsed: true,
items: [
'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-blackbox',
@ -364,6 +385,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'blockchain',
key: 'skills-optional-blockchain',
collapsed: true,
items: [
'user-guide/skills/optional/blockchain/blockchain-evm',
@ -374,6 +396,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'communication',
key: 'skills-optional-communication',
collapsed: true,
items: [
'user-guide/skills/optional/communication/communication-one-three-one-rule',
@ -382,6 +405,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'creative',
key: 'skills-optional-creative',
collapsed: true,
items: [
'user-guide/skills/optional/creative/creative-blender-mcp',
@ -394,6 +418,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'devops',
key: 'skills-optional-devops',
collapsed: true,
items: [
'user-guide/skills/optional/devops/devops-cli',
@ -404,6 +429,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'dogfood',
key: 'skills-optional-dogfood',
collapsed: true,
items: [
'user-guide/skills/optional/dogfood/dogfood-adversarial-ux-test',
@ -412,6 +438,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'email',
key: 'skills-optional-email',
collapsed: true,
items: [
'user-guide/skills/optional/email/email-agentmail',
@ -420,6 +447,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'finance',
key: 'skills-optional-finance',
collapsed: true,
items: [
'user-guide/skills/optional/finance/finance-3-statement-model',
@ -435,6 +463,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'health',
key: 'skills-optional-health',
collapsed: true,
items: [
'user-guide/skills/optional/health/health-fitness-nutrition',
@ -444,6 +473,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'mcp',
key: 'skills-optional-mcp',
collapsed: true,
items: [
'user-guide/skills/optional/mcp/mcp-fastmcp',
@ -453,6 +483,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'migration',
key: 'skills-optional-migration',
collapsed: true,
items: [
'user-guide/skills/optional/migration/migration-openclaw-migration',
@ -461,6 +492,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'mlops',
key: 'skills-optional-mlops',
collapsed: true,
items: [
'user-guide/skills/optional/mlops/mlops-accelerate',
@ -496,6 +528,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'productivity',
key: 'skills-optional-productivity',
collapsed: true,
items: [
'user-guide/skills/optional/productivity/productivity-canvas',
@ -510,6 +543,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'research',
key: 'skills-optional-research',
collapsed: true,
items: [
'user-guide/skills/optional/research/research-bioinformatics',
@ -526,6 +560,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'security',
key: 'skills-optional-security',
collapsed: true,
items: [
'user-guide/skills/optional/security/security-1password',
@ -536,6 +571,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'software-development',
key: 'skills-optional-software-development',
collapsed: true,
items: [
'user-guide/skills/optional/software-development/software-development-rest-graphql-debug',
@ -544,6 +580,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'web-development',
key: 'skills-optional-web-development',
collapsed: true,
items: [
'user-guide/skills/optional/web-development/web-development-page-agent',