diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 1c7dd9f7497..cfc44e5f2a6 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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 diff --git a/cli.py b/cli.py index 50e7a8c8ce9..241d41e9fcd 100644 --- a/cli.py +++ b/cli.py @@ -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: diff --git a/run_agent.py b/run_agent.py index 85c1128d68e..b3cde9eb1ea 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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( diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 9dd85762956..96f5802f839 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -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.""" diff --git a/tests/tools/test_tirith_security.py b/tests/tools/test_tirith_security.py index ecaf4f4e639..afeb14f9458 100644 --- a/tests/tools/test_tirith_security.py +++ b/tests/tools/test_tirith_security.py @@ -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) # --------------------------------------------------------------------------- diff --git a/tools/tirith_security.py b/tools/tirith_security.py index 1c79892f424..b45d7d29213 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -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"] diff --git a/ui-tui/src/__tests__/approvalAction.test.ts b/ui-tui/src/__tests__/approvalAction.test.ts new file mode 100644 index 00000000000..851b5093448 --- /dev/null +++ b/ui-tui/src/__tests__/approvalAction.test.ts @@ -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' }) + }) +}) diff --git a/ui-tui/src/__tests__/useInputHandlers.test.ts b/ui-tui/src/__tests__/useInputHandlers.test.ts index 066292abfa5..0d3fd69c1ed 100644 --- a/ui-tui/src/__tests__/useInputHandlers.test.ts +++ b/ui-tui/src/__tests__/useInputHandlers.test.ts @@ -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', () => { diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index ce25af70edd..59de48a310d 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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)) { diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index e9d42485d9b..3dfd31be869 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -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) { ))} - ↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny + ↑/↓ select · Enter confirm · 1-4 quick pick · Esc/Ctrl+C deny ) } diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index fca8a99a248..2a48deb2448 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -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 diff --git a/website/scripts/generate-skill-docs.py b/website/scripts/generate-skill-docs.py index 2a0694a61c8..c932f01e1bc 100755 --- a/website/scripts/generate-skill-docs.py +++ b/website/scripts/generate-skill-docs.py @@ -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: [") diff --git a/website/sidebars.ts b/website/sidebars.ts index fe7b741eb2e..52ed452d046 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -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',