diff --git a/.env.example b/.env.example index 4c83db1f3b4..6eac3487e9a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,13 @@ # Hermes Agent Environment Configuration # Copy this file to .env and fill in your API keys +# ============================================================================= +# LLM PROVIDER (Fireworks AI) +# ============================================================================= +# Get your key at: https://app.fireworks.ai/settings/users/api-keys +# Address models directly by catalog ID, e.g. +# accounts/fireworks/models/kimi-k2p6, accounts/fireworks/models/glm-5p2 +# FIREWORKS_API_KEY= # ============================================================================= # LLM PROVIDER (OpenRouter) # ============================================================================= @@ -108,6 +115,10 @@ # HF_BASE_URL=https://router.huggingface.co/v1 # Override default base URL # OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL +# DeepInfra — 100+ top open models, pay-per-use. +# Get your key at: https://deepinfra.com/dash/api_keys +# DEEPINFRA_API_KEY= + # ============================================================================= # LLM PROVIDER (Qwen OAuth) # ============================================================================= diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index 268b0aa103c..b2ab85be5bc 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -10,7 +10,7 @@ outputs: description: Run Python tests / ruff / ty / windows-footguns. value: ${{ steps.classify.outputs.python }} frontend: - description: Run the TypeScript typecheck matrix + desktop build. + description: Run the TypeScript testing matrix + desktop build. value: ${{ steps.classify.outputs.frontend }} docker_meta: description: Docker setup and meta files have changed. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab601201653..7840bfdc361 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,11 +70,11 @@ jobs: with: event_name: ${{ needs.detect.outputs.event_name }} - typecheck: - name: TypeScript + js-tests: + name: JS & TS checks needs: detect if: needs.detect.outputs.frontend == 'true' - uses: ./.github/workflows/typecheck.yml + uses: ./.github/workflows/js-tests.yml docs-site: name: Docs Site @@ -139,7 +139,7 @@ jobs: needs: - tests - lint - - typecheck + - js-tests - docs-site - history-check - contributor-check @@ -154,14 +154,18 @@ jobs: steps: - name: Evaluate job results env: - RESULTS: ${{ toJSON(needs.*.result) }} + NEEDS: ${{ toJSON(needs) }} run: | - echo "$RESULTS" | python3 -c " + echo "$NEEDS" | python3 -c " import json, sys - results = json.load(sys.stdin) - failed = [r for r in results if r == 'failure'] + needs = json.load(sys.stdin) + failed = [name for name, info in needs.items() if info['result'] == 'failure'] + for name, info in sorted(needs.items()): + result = info['result'] + icon = '✅' if result in ('success', 'skipped') else '❌' + print(f'{icon} {name}: {result}') if failed: - print(f'::error::{len(failed)} job(s) failed') + print(f'::error::{len(failed)} job(s) failed: {\", \".join(failed)}') sys.exit(1) print('All checks passed (or were skipped)') " diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml new file mode 100644 index 00000000000..db08947b270 --- /dev/null +++ b/.github/workflows/js-tests.yml @@ -0,0 +1,49 @@ +# .github/workflows/js-tests.yml +name: JS Tests + +on: + workflow_call: + +jobs: + workspaces: + name: List npm workspaces + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.set-matrix.outputs.packages }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + - uses: ./.github/actions/retry + with: + command: npm ci --ignore-scripts + - id: set-matrix + run: | + PACKAGES=$(npm query .workspace | jq -c '[.[].location]') + if [ "$PACKAGES" = "[]" ] || [ -z "$PACKAGES" ]; then + echo "::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently)." + exit 1 + fi + echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT" + + check: + name: Typecheck & Test + runs-on: ubuntu-latest + needs: workspaces + strategy: + matrix: + package: ${{ fromJson(needs.workspaces.outputs.packages) }} + fail-fast: false # report all failures, not just the first one + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + - uses: ./.github/actions/retry + with: + # --ignore-scripts: TS & tests don't need native deps + command: npm ci --ignore-scripts + - run: npm run --prefix ${{ matrix.package }} check diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml deleted file mode 100644 index dd2906629b0..00000000000 --- a/.github/workflows/typecheck.yml +++ /dev/null @@ -1,51 +0,0 @@ -# .github/workflows/typecheck.yml -name: Typecheck - -on: - workflow_call: - -jobs: - typecheck: - name: Check TypeScript - runs-on: ubuntu-latest - strategy: - matrix: - package: - [ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared] - fail-fast: false # report all failures, not just the first one - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - cache: npm - # --ignore-scripts: typecheck only needs the TS sources + type defs, not - # native builds. Skipping install scripts drops node-pty's node-gyp - # header fetch — the transient flake that killed this job pre-`tsc` — and - # is faster. retry covers the remaining registry blips. - - uses: ./.github/actions/retry - with: - command: npm ci --ignore-scripts - - run: npm run --prefix ${{ matrix.package }} typecheck - - # Production build of the desktop renderer. `typecheck` runs `tsc` only, - # which does NOT exercise Vite/Rolldown module resolution — so an - # unresolvable package export (e.g. a transitive @assistant-ui/tap that no - # longer exports "./react-shim") slips past typecheck and only explodes when - # users build apps/desktop from source on install/update. Run the real - # `vite build` here so that class of break fails in CI instead. - desktop-build: - name: Build desktop app - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - cache: npm - # Keep install scripts here: the production build may need node-pty's - # native binary. retry handles the transient install-time fetch flakes. - - uses: ./.github/actions/retry - with: - command: npm ci - - run: npm run --prefix apps/desktop build diff --git a/.gitignore b/.gitignore index e4240ea36e7..4d25e5425b6 100644 --- a/.gitignore +++ b/.gitignore @@ -69,7 +69,7 @@ hermes_cli/web_dist/ apps/desktop/build/ apps/desktop/dist/ apps/desktop/release/ -apps/desktop/*.tsbuildinfo +*.tsbuildinfo # Web UI assets — synced from @nous-research/ui at build time via # `npm run sync-assets` (see web/package.json). @@ -119,6 +119,9 @@ docs/superpowers/* # treat it as a local edit and autostash it on every run (#38529). .hermes-bootstrap-complete +# Persistent dev sandbox dir (scripts/dev-sandbox.sh --persistent) +.hermes-sandbox/ + # Interrupted-update breadcrumb + recovery lock written next to the shared venv # by `hermes update` / launch-time self-heal. Runtime state, never a code change # — ignore so `git status` stays clean and update's autostash skips them. diff --git a/AGENTS.md b/AGENTS.md index e1dbaaa5c43..78a150ab0c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). --- @@ -1354,3 +1354,58 @@ not the specific names. Reviewers should reject new change-detector tests; authors should convert them into invariants before re-requesting review. + +### Never read source code in tests + +A test that reads a source file's text is testing *the shape of the +source code*, not its behavior. This is a hard antipattern, banned outright. +Any test that reads a .py, .ts, .tsx, etc., file is suspect. + +**Why it's actively harmful, not just weak:** + +- It passes when the implementation is subtly broken (the regex matches a + call site that exists but is wired wrong) and fails when a correct + refactor changes formatting, variable names, or control flow with + identical runtime behavior. Both directions of failure are wrong. +- It can't be run against a built/bundled/minified artifact, so it silently + stops testing anything the moment code moves, gets renamed, or a + dependency reformats it. +- It actively blocks refactors: reviewers see "keeps a pattern intact" tests + fail during pure structural cleanup with no behavior change, and either + hand-wave the failure (dangerous) or waste time updating regexes that add + nothing (waste). +- It gives false confidence. a green suite full of source-regex tests + looks like coverage but has never once executed the code path it claims + to guard. + +**Do not write:** + +```ts +const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8') + +test('backend spawn hides the Windows console', () => { + assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/) +}) +``` + +**Do write — extract the logic into a small pure/DI-testable function and +call it for real:** + +```ts +// backend-spawn.ts +export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') { + if (!isWindows || 'windowsHide' in options) return options + return { ...options, windowsHide: true } +} + +// backend-spawn.test.ts +test('windowsHide defaults to true on Windows, is left alone elsewhere', () => { + assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true) + assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined) + assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false) +}) +``` + +If the logic lives inline in a god-file (`main.ts`, `cli.py`, +`gateway/run.py`) and extracting it feels disruptive: that's the actual +signal to do the extraction, not to regex around it. diff --git a/acp_adapter/permissions.py b/acp_adapter/permissions.py index 29bd101edd9..5f29a96725c 100644 --- a/acp_adapter/permissions.py +++ b/acp_adapter/permissions.py @@ -38,19 +38,22 @@ def _permission_option_supports_kind(kind: str) -> bool: return True -def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]: +def _build_permission_options( + *, allow_permanent: bool, smart_denied: bool = False, +) -> list[PermissionOption]: """Return ACP options that match Hermes approval semantics.""" - options = [ - PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"), - PermissionOption( + options = [PermissionOption( + option_id="allow_once", kind="allow_once", name="Allow once", + )] + if not smart_denied: + options.append(PermissionOption( option_id="allow_session", # ACP has no session-scoped kind, so use the closest persistent # hint while keeping Hermes semantics in the option id. kind="allow_always", name="Allow for session", - ), - ] - if allow_permanent: + )) + if allow_permanent and not smart_denied: options.append( PermissionOption( option_id="allow_always", @@ -59,7 +62,7 @@ def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption ), ) options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny")) - if _permission_option_supports_kind("reject_always"): + if not smart_denied and _permission_option_supports_kind("reject_always"): options.append( PermissionOption( option_id="deny_always", @@ -129,11 +132,15 @@ def make_approval_callback( description: str, *, allow_permanent: bool = True, + smart_denied: bool = False, **_: object, ) -> str: from agent.async_utils import safe_schedule_threadsafe - options = _build_permission_options(allow_permanent=allow_permanent) + options = _build_permission_options( + allow_permanent=allow_permanent, + smart_denied=smart_denied, + ) tool_call = _build_permission_tool_call(command, description) coro = request_permission_fn( diff --git a/acp_adapter/session.py b/acp_adapter/session.py index b048fae510f..a51c4c58aa2 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -26,31 +26,18 @@ from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) -def _win_path_to_wsl(path: str) -> str | None: - """Convert a Windows drive path to its WSL /mnt//... equivalent.""" - match = re.match(r"^([A-Za-z]):[\\/](.*)$", path) - if not match: - return None - drive = match.group(1).lower() - tail = match.group(2).replace("\\", "/") - return f"/mnt/{drive}/{tail}" - - def _translate_acp_cwd(cwd: str) -> str: """Translate Windows ACP cwd values when Hermes itself is running in WSL. Windows ACP clients can launch ``hermes acp`` inside WSL while still sending - editor workspaces as Windows drive paths such as ``E:\\Projects``. Store - and execute against the WSL mount path so agents, tools, and persisted ACP - sessions all agree on the usable workspace. Native Linux/macOS keeps the - original cwd unchanged. + editor workspaces as Windows drive paths (``E:\\Projects``) or + ``\\\\wsl.localhost\\`` UNC paths. Store and execute against the POSIX form so + agents, tools, and persisted ACP sessions all agree on the usable workspace. + Native Linux/macOS keeps the original cwd unchanged. """ - from hermes_constants import is_wsl + from hermes_constants import translate_cwd_for_wsl_backend - if not is_wsl(): - return cwd - translated = _win_path_to_wsl(str(cwd)) - return translated if translated is not None else cwd + return translate_cwd_for_wsl_backend(str(cwd)) def _normalize_cwd_for_compare(cwd: str | None) -> str: @@ -61,7 +48,9 @@ def _normalize_cwd_for_compare(cwd: str | None) -> str: # Normalize Windows drive paths into the equivalent WSL mount form so # ACP history filters match the same workspace across Windows and WSL. - translated = _win_path_to_wsl(expanded) + from hermes_constants import windows_path_to_wsl + + translated = windows_path_to_wsl(expanded) if translated is not None: expanded = translated elif re.match(r"^/mnt/[A-Za-z]/", expanded): diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index cbf7d15b3b8..fce98cf54e6 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -110,7 +110,12 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str: if tool_name == "web_extract": urls = args.get("urls", []) if urls: - return f"extract: {urls[0]}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "") + first = urls[0] + if isinstance(first, dict): + first = first.get("url") or first.get("href") or "?" + elif not isinstance(first, str): + first = "?" + return f"extract: {first}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "") return "web extract" if tool_name == "process": action = str(args.get("action") or "").strip() or "manage" diff --git a/agent/agent_init.py b/agent/agent_init.py index ce32645df78..5b2e2c51693 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -77,8 +77,8 @@ def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str: include the exact opt-back-out command. """ model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1] - # gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5 family is - # capped at 272K by the Codex OAuth backend. + # gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6 family + # is capped at 272K by the Codex OAuth backend. cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K" from_pct = int(round(autoraise["from"] * 100)) to_pct = int(round(autoraise["to"] * 100)) @@ -187,10 +187,26 @@ def _normalized_custom_base_url(value: Any) -> str: def _custom_provider_model_matches(agent_model: str, entry: Dict[str, Any]) -> bool: - provider_model = str(entry.get("model", "") or "").strip().lower() - if not provider_model: + agent_model_norm = str(agent_model or "").strip().lower() + # Multi-model entries (v12+ `providers..models` mapping / legacy + # `models:` list): the agent's model matching ANY catalog entry counts. + # Without this, a provider whose `model`/`default_model` differs from the + # session model silently fails to match and per-provider request settings + # (extra_body, e.g. OpenAI service_tier) are dropped — billing the whole + # session at the wrong tier (July 2026 sweeper incident: flex config + # ignored, ~2.3x overbilling). + models = entry.get("models") + catalog: List[str] = [] + if isinstance(models, dict): + catalog = [str(k).strip().lower() for k in models.keys()] + elif isinstance(models, (list, tuple)): + catalog = [str(m).strip().lower() for m in models] + if catalog and agent_model_norm in catalog: return True - return provider_model == str(agent_model or "").strip().lower() + provider_model = str(entry.get("model", "") or "").strip().lower() + if not provider_model and not catalog: + return True + return provider_model == agent_model_norm def _custom_provider_extra_body_for_agent( @@ -302,6 +318,7 @@ def init_agent( notice_callback: callable = None, notice_clear_callback: callable = None, event_callback: Optional[Callable[[str, dict], None]] = None, + reaction_callback: Optional[Callable[[str], None]] = None, max_tokens: int = None, reasoning_config: Dict[str, Any] = None, service_tier: str = None, @@ -411,13 +428,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"}: @@ -535,6 +564,7 @@ def init_agent( agent.notice_callback = notice_callback agent.notice_clear_callback = notice_clear_callback agent.event_callback = event_callback + agent.reaction_callback = reaction_callback agent.tool_gen_callback = tool_gen_callback diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 6f57f83e977..1cde73419a4 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1208,7 +1208,42 @@ def restore_primary_runtime(agent) -> bool: api_mode=rt.get("compressor_api_mode", ""), ) - # ── Re-select from the credential pool if one is available ── + # ── Rebind and re-select the primary credential pool ── + # A cross-provider fallback attaches the fallback provider's pool. The + # runtime fields above restore the primary, but leaving that pool in + # place makes the next primary 401/429 hit the provider-mismatch guard + # and disables credential rotation. Reload the primary pool first; if + # auth storage is temporarily unreadable, clear the mismatched pool. + primary_provider = str(rt.get("provider") or "").strip().lower() + pool = getattr(agent, "_credential_pool", None) + pool_provider = str(getattr(pool, "provider", "") or "").strip().lower() + pool_matches_primary = pool_provider == primary_provider + if ( + primary_provider == "custom" + and pool_provider.startswith("custom:") + ): + try: + from agent.credential_pool import get_custom_provider_pool_key + + primary_key = ( + get_custom_provider_pool_key(str(rt.get("base_url") or "")) or "" + ).strip().lower() + pool_matches_primary = bool(primary_key) and primary_key == pool_provider + except Exception: + pool_matches_primary = False + if pool is not None and pool_provider and not pool_matches_primary: + agent._credential_pool = None + try: + from agent.credential_pool import load_pool + + agent._credential_pool = load_pool(primary_provider) + except Exception as exc: + logger.warning( + "Restore could not reload primary credential pool for %s: %s", + primary_provider, + exc, + ) + # The snapshot's api_key was captured at construction time. Across # turns the pool may have rotated (token revocation, billing/rate-limit # exhaustion, cooldown), leaving the snapshot key stale. Restoring it @@ -1222,7 +1257,6 @@ def restore_primary_runtime(agent) -> bool: entry = pool.select() if entry is not None: entry_provider = str(getattr(entry, "provider", "") or "").strip().lower() - primary_provider = str(rt.get("provider") or "").strip().lower() entry_matches_primary = entry_provider == primary_provider # Custom endpoints all carry the generic ``custom`` provider on # the agent while the pool entry is keyed ``custom:`` (see @@ -1564,6 +1598,17 @@ def anthropic_prompt_cache_policy( model_lower = eff_model.lower() provider_lower = eff_provider.lower() is_claude = "claude" in model_lower + # Kimi / Moonshot family via OpenRouter: same cache_control wire format + # as Claude on OpenRouter (envelope layout). Without this branch + # moonshotai/kimi-k2.6 falls through to (False, False), serving ~1% + # cache hits on 64K-token prompts and re-billing the full prompt on + # every turn. Observed within-turn progression with cache enabled: + # 1% → 67% → 84% → 97% (#25970). Reuses the canonical family matcher + # (covers bare k1./k2./k25 release slugs the substring check missed). + from agent.anthropic_adapter import _model_name_is_kimi_family + is_kimi = ( + _model_name_is_kimi_family(eff_model) or "moonshot" in model_lower + ) is_openrouter = base_url_host_matches(eff_base_url, "openrouter.ai") # Nous Portal proxies to OpenRouter behind the scenes — identical # OpenAI-wire envelope cache_control semantics. Treat it as an @@ -1577,7 +1622,7 @@ def anthropic_prompt_cache_policy( if is_native_anthropic: return True, True - if (is_openrouter or is_nous_portal) and is_claude: + if (is_openrouter or is_nous_portal) and (is_claude or is_kimi): return True, False # Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout # cache_control path as Portal Claude. Portal proxies to OpenRouter @@ -1805,13 +1850,30 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # ── Swap core runtime fields ── agent.model = new_model agent.provider = new_provider - # Use new base_url when provided; only fall back to current when the - # new provider genuinely has no endpoint (e.g. native SDK providers). - # Without this guard the old provider's URL (e.g. Ollama's localhost - # address) would persist silently after switching to a cloud provider - # that returns an empty base_url string. + # Use the new base_url when provided. When it's empty AND the + # provider is actually changing, do NOT fall back to the current + # (old provider's) URL — that silently pairs the new provider label + # with the previous provider's endpoint (e.g. new_provider=minimax + # paired with the leftover api.githubcopilot.com URL), and every + # request after the switch 400s at the wrong host. This mismatched + # pair also gets snapshotted into _primary_runtime below, so it + # keeps re-applying on every subsequent turn until a full restart. + # Fail loud instead: the caller (model_switch.switch_model()) + # already resolves base_url for every real provider, so an empty + # value here means resolution failed upstream, not that the + # provider genuinely has none. Re-selecting the SAME provider with + # an empty base_url (e.g. a credential-only refresh) is still fine + # to keep the current URL. See #47828. + old_norm_provider = (old_provider or "").strip().lower() + new_norm_provider = (new_provider or "").strip().lower() if base_url: agent.base_url = base_url + elif old_norm_provider != new_norm_provider: + raise ValueError( + f"switch_model: no base_url resolved for provider " + f"'{new_provider}' (switching from '{old_provider}'); " + "refusing to keep the previous provider's endpoint" + ) agent.api_mode = api_mode # Invalidate transport cache — new api_mode may need a different transport if hasattr(agent, "_transport_cache"): @@ -1830,6 +1892,9 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo old_norm = (old_provider or "").strip().lower() new_norm = (new_provider or "").strip().lower() if old_norm != new_norm or getattr(agent, "_credential_pool", None) is None: + # A pool bound to the old provider is worse than no pool: the + # recovery guard rejects it and every later 401/429 skips rotation. + agent._credential_pool = None try: from agent.credential_pool import load_pool agent._credential_pool = load_pool(new_provider) @@ -1925,6 +1990,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo _sm_timeout = get_provider_request_timeout(agent.provider, agent.model) if _sm_timeout is not None: agent._client_kwargs["timeout"] = _sm_timeout + # Reapply provider-specific headers (e.g. OpenRouter HTTP-Referer, + # X-Title) that were lost when _client_kwargs was rebuilt from + # scratch. Without this, model switches clear attribution headers + # and OpenRouter logs show "Unknown" for subsequent requests. + agent._apply_client_headers_for_base_url(effective_base) agent.client = agent._create_openai_client( dict(agent._client_kwargs), reason="switch_model", diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 623560250df..689d01010ad 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -65,6 +65,7 @@ THINKING_BUDGET = {"xhigh": 32000, "high": 16000, "medium": 8000, "low": 4000} # maps to low on every model. See: # https://platform.claude.com/docs/en/about-claude/models/migration-guide ADAPTIVE_EFFORT_MAP = { + "ultra": "max", "max": "max", "xhigh": "xhigh", "high": "high", @@ -2102,7 +2103,7 @@ def _convert_user_message(content: Any) -> Dict[str, Any]: if isinstance(content, list): converted_blocks = _convert_content_to_anthropic(content) if not converted_blocks or all( - b.get("text", "").strip() == "" + (b.get("text") or "").strip() == "" for b in converted_blocks if isinstance(b, dict) and b.get("type") == "text" ): diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index d55f7df4fcd..e39017be0d7 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -314,15 +314,18 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool: return bare == "trinity-large-thinking" -# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.4/5.5. -# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the -# Codex backend hard-caps at 272K (verified live: a ~330K-token request to +# Context window enforced by ChatGPT's Codex OAuth backend for the +# gpt-5.4 / gpt-5.5 / gpt-5.6 families. The raw OpenAI API and OpenRouter +# expose 1.05M for the same slugs, but the Codex backend hard-caps at 272K +# (verified live for 5.4/5.5: a ~330K-token request to # chatgpt.com/backend-api/codex/responses is rejected with -# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the -# default 50% compaction trigger fires at ~136K — wasteful, since the model -# can hold far more raw context before summarization actually buys anything. -# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.4/ -# gpt-5.5 sessions use the window they actually have. +# ``context_length_exceeded`` while ~250K succeeds; gpt-5.6 shares the same +# 272K Codex cap — see _CODEX_OAUTH_CONTEXT_FALLBACK in model_metadata.py). +# With a 272K ceiling the default 50% compaction trigger fires at ~136K — +# wasteful, since the model can hold far more raw context before +# summarization actually buys anything. We raise the trigger to 85% (~231K) +# on this exact route so Codex gpt-5.4 / gpt-5.5 / gpt-5.6 sessions use the +# window they actually have. _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85 # gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a @@ -336,14 +339,16 @@ _CODEX_SPARK_COMPACTION_THRESHOLD = 0.70 def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool: - """True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend. + """True for gpt-5.4 / gpt-5.5 / gpt-5.6 on the ChatGPT Codex OAuth backend. Matches only the Codex OAuth route (provider ``openai-codex``), not the direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a larger context window for the same slug and must keep the user's default - compaction threshold. ``gpt-5.4-pro`` / ``gpt-5.5-pro`` and dated snapshots - are matched via prefix so the override tracks both 272K-capped families - without re-listing every variant. + compaction threshold. ``-pro`` variants and dated snapshots are matched + via prefix so the override tracks every 272K-capped family (5.4, 5.5, + 5.6 sol/terra/luna incl. their ``-pro`` modes) without re-listing every + variant. (Name kept for backward compatibility with the + ``compression.codex_gpt55_autoraise`` config key.) """ prov = (provider or "").strip().lower() if prov != "openai-codex": @@ -356,6 +361,9 @@ def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = Non or bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.") + or bare == "gpt-5.6" + or bare.startswith("gpt-5.6-") + or bare.startswith("gpt-5.6.") ) @@ -410,11 +418,12 @@ def _compression_threshold_for_model( Per-model/route overrides: - Arcee Trinity Large Thinking → 0.75 (preserve reasoning context). - - gpt-5.4 / gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps - both families at 272K and the default 50% trigger would compact at - ~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key - name kept for backward compatibility) so the user can opt back down to - the global default (the caller passes the config flag through here). + - gpt-5.4 / gpt-5.5 / gpt-5.6 on the Codex OAuth route → 0.85, because + Codex caps all three families at 272K and the default 50% trigger + would compact at ~136K. Gated by ``allow_codex_gpt55_autoraise`` + (historical config-key name kept for backward compatibility) so the + user can opt back down to the global default (the caller passes the + config flag through here). - gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model has a native 128K window and the default 50% trigger would compact at ~64K — wasting half the usable context. Not gated by the gpt-5.5 @@ -465,6 +474,10 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = { "kilocode": "google/gemini-3-flash-preview", "ollama-cloud": "nemotron-3-nano:30b", "tencent-tokenhub": "hy3-preview", + # NB: no "deepinfra" entry — its aux model lives on the ProviderProfile + # (plugins/model-providers/deepinfra: default_aux_model), which + # _get_aux_model_for_provider() reads first. Duplicating it here would be + # dead data that drifts when the profile's value is bumped. } # Legacy alias — callers that haven't been updated to _get_aux_model_for_provider() @@ -480,6 +493,33 @@ _PROVIDER_VISION_MODELS: Dict[str, str] = { "zai": "glm-5v-turbo", } + +def _resolve_provider_vision_default(provider: str) -> Optional[str]: + """Return the provider's preferred default vision model id, or None. + + Static entries in :data:`_PROVIDER_VISION_MODELS` win first (xiaomi / + zai have dedicated vision-only model names that don't live in any + discoverable catalog). Otherwise the provider's :class:`ProviderProfile` + gets a chance to supply one via its ``default_vision_model()`` hook — + that's where catalog-backed providers (DeepInfra) resolve a live default, + keeping the discovery logic inside their plugin instead of a name-check + branch here. + """ + static = _PROVIDER_VISION_MODELS.get(provider) + if static: + return static + try: + from providers import get_provider_profile + profile = get_provider_profile(provider) + except Exception: + return None + if profile is None: + return None + try: + return profile.default_vision_model() + except Exception: + return None + # Providers whose endpoint does not accept image input, even though the # provider's broader ecosystem has vision models available elsewhere. When # `auxiliary.vision.provider: auto` sees one of these as the main provider, @@ -863,6 +903,7 @@ class _CodexCompletionsAdapter: # `function_call_output` items with a valid call_id, so every # Responses path normalizes tool history identically and cannot drift. from agent.codex_responses_adapter import _chat_messages_to_responses_input + from utils import base_url_host_matches instructions = "You are a helpful assistant." replay_messages: List[Dict[str, Any]] = [] @@ -874,7 +915,18 @@ class _CodexCompletionsAdapter: else: replay_messages.append(msg) - input_items = _chat_messages_to_responses_input(replay_messages) + # Copilot (githubcopilot.com) binds replayed codex_message_items ids + # to a backend "connection" that doesn't survive credential + # rotation/gateway restarts — replaying one gets HTTP 401 "input + # item ID does not belong to this connection" (#32716). Auxiliary + # calls (context compression, flush_memories, MoA aggregation) go + # through this adapter instead of agent/transports/codex.py's + # build_kwargs, so they need the same guard applied independently. + _host_for_input = str(getattr(self._client, "base_url", "") or "") + _is_github_for_input = base_url_host_matches(_host_for_input, "githubcopilot.com") + input_items = _chat_messages_to_responses_input( + replay_messages, is_github_responses=_is_github_for_input, + ) resp_kwargs: Dict[str, Any] = { "model": model, @@ -4707,8 +4759,8 @@ def resolve_provider_client( if custom_entry is None: custom_entry = _get_named_custom_provider(provider) if custom_entry: - custom_base = custom_entry.get("base_url", "").strip() - custom_key = custom_entry.get("api_key", "").strip() + custom_base = (custom_entry.get("base_url") or "").strip() + custom_key = (custom_entry.get("api_key") or "").strip() custom_key_env = (custom_entry.get("key_env") or custom_entry.get("api_key_env") or "").strip() if not custom_key and custom_key_env: custom_key = os.getenv(custom_key_env, "").strip() @@ -5157,6 +5209,7 @@ def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Di _VISION_AUTO_PROVIDER_ORDER = ( "openrouter", "nous", + "deepinfra", ) @@ -5213,6 +5266,21 @@ def _resolve_strict_vision_backend( return resolve_provider_client("openai-codex", model, is_vision=True) if provider == "anthropic": return _try_anthropic() + if provider == "deepinfra": + # DeepInfra exposes vision-capable models (Llama-4 Scout/Maverick, + # Qwen3-VL, Gemma 3, Gemini) on the same OpenAI-compatible endpoint + # as its chat models. The default is discovered live via the profile's + # default_vision_model() hook (key-gated, chat-surface + vision tag) so + # we don't pin a hardcoded id that may rot when DeepInfra retires a + # model, and this module stays provider-agnostic. + vision_model = model or _resolve_provider_vision_default("deepinfra") + if not vision_model: + logger.debug( + "Vision auto-detect: deepinfra catalog unreachable or " + "returned no vision-tagged models — skipping" + ) + return None, None + return resolve_provider_client("deepinfra", vision_model, is_vision=True) if provider == "custom": return _try_custom_endpoint() return None, None @@ -5298,16 +5366,29 @@ def resolve_vision_provider_client( # _PROVIDER_VISION_MODELS provides per-provider vision model # overrides when the provider has a dedicated multimodal model # that differs from the chat model (e.g. xiaomi → mimo-v2-omni, - # zai → glm-5v-turbo). Nous is the exception: it has a dedicated - # strict vision backend with tier-aware defaults, so it must not - # fall through to the user's text chat model here. - # 2. OpenRouter (vision-capable aggregator fallback) + # zai → glm-5v-turbo). DeepInfra is similar but resolves its + # default vision model live from the catalog (see + # :func:`_resolve_provider_vision_default`). Nous is the + # exception: it has a dedicated strict vision backend with + # tier-aware defaults, so it must not fall through to the + # user's text chat model here. + # 2. OpenRouter (vision-capable aggregator fallback) # 3. Nous Portal (vision-capable aggregator fallback) - # 4. Stop + # 4. DeepInfra (OpenAI-compatible; vision model discovered + # live from the catalog — tried when + # DEEPINFRA_API_KEY is set) + # 5. Stop main_provider = _read_main_provider() main_model = _read_main_model() if main_provider and main_provider not in {"auto", ""}: - vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model) + # A provider-specific vision default wins over the user's chat model: + # static overrides (xiaomi/zai) and catalog-backed discovery (the + # DeepInfra profile hook) both yield a *known* vision-capable model, + # whereas the pinned chat model is usually NOT multimodal (e.g. the + # DeepSeek-V4-Flash default) and _main_model_supports_vision can't be + # trusted to catch that. Only fall back to the chat model when no + # provider default is available (catalog unreachable). + vision_model = _resolve_provider_vision_default(main_provider) or main_model if main_provider == "nous": sync_client, default_model = _resolve_strict_vision_backend( main_provider, vision_model diff --git a/agent/background_review.py b/agent/background_review.py index 3f4e5efcd37..bf78f679236 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -62,6 +62,11 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]: "api_key": parent_runtime.get("api_key") or None, "base_url": parent_runtime.get("base_url") or None, "api_mode": parent_api_mode, + "credential_pool": getattr(agent, "_credential_pool", None), + "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), + "max_tokens": getattr(agent, "max_tokens", None), + "command": getattr(agent, "acp_command", None), + "args": list(getattr(agent, "acp_args", []) or []), "routed": False, } try: @@ -89,10 +94,15 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]: ) return { "provider": rp.get("provider") or task_provider, - "model": task_model, + "model": rp.get("model") or task_model, "api_key": rp.get("api_key"), "base_url": rp.get("base_url"), "api_mode": rp.get("api_mode"), + "credential_pool": rp.get("credential_pool"), + "request_overrides": dict(rp.get("request_overrides") or {}), + "max_tokens": rp.get("max_output_tokens"), + "command": rp.get("command"), + "args": list(rp.get("args") or []), "routed": True, } except Exception as e: @@ -680,6 +690,12 @@ def _run_review_in_thread( # Match parent's toolset config so ``tools[]`` is byte-identical # in the request body — Anthropic's cache key includes it. # (The runtime whitelist below still restricts dispatch.) + _fork_kwargs: Dict[str, Any] = {} + if isinstance(_rt.get("max_tokens"), int): + _fork_kwargs["max_tokens"] = _rt["max_tokens"] + if isinstance(_rt.get("command"), str) and _rt["command"]: + _fork_kwargs["acp_command"] = _rt["command"] + _fork_kwargs["acp_args"] = _rt.get("args") or [] review_agent = AIAgent( model=_rt.get("model") or agent.model, max_iterations=16, @@ -689,11 +705,13 @@ def _run_review_in_thread( api_mode=_rt.get("api_mode"), base_url=_rt.get("base_url") or None, api_key=_rt.get("api_key") or None, - credential_pool=getattr(agent, "_credential_pool", None), + credential_pool=_rt.get("credential_pool"), + request_overrides=_rt.get("request_overrides") or {}, parent_session_id=agent.session_id, enabled_toolsets=getattr(agent, "enabled_toolsets", None), disabled_toolsets=getattr(agent, "disabled_toolsets", None), skip_memory=True, + **_fork_kwargs, ) review_agent._memory_write_origin = "background_review" review_agent._memory_write_context = "background_review" diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 1c1deaa88fc..fa4216e2f83 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -164,6 +164,25 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]: return None +def _provider_preferences_for_agent(agent) -> Dict[str, Any]: + """Build the validated provider-routing object shared by request paths.""" + preferences: Dict[str, Any] = {} + if agent.providers_allowed: + preferences["only"] = agent.providers_allowed + if agent.providers_ignored: + preferences["ignore"] = agent.providers_ignored + if agent.providers_order: + preferences["order"] = agent.providers_order + provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) + if provider_sort: + preferences["sort"] = provider_sort + if agent.provider_require_parameters: + preferences["require_parameters"] = True + if agent.provider_data_collection: + preferences["data_collection"] = agent.provider_data_collection + return preferences + + def _env_float(name: str, default: float) -> float: try: return float(os.getenv(name, str(default))) @@ -217,6 +236,129 @@ def _check_stale_giveup(agent) -> None: ) +def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client): + """Run one non-streaming LLM request for the active api_mode and return it. + + Shared by the interrupt-worker path (``interruptible_api_call``) and the + inline path (``direct_api_call``) so the per-api_mode dispatch — codex / + anthropic / bedrock / MoA / OpenAI-compatible — lives in exactly one place. + + ``make_client(reason)`` builds the per-request OpenAI client for the codex + and OpenAI-compatible branches; the worker path uses it to register the + client with its stranger-thread abort machinery, the inline path uses it to + capture the client for its own ``finally`` close. The anthropic / bedrock / + MoA branches manage their own clients and never call it. All interrupt, + abort, cancellation, and close semantics stay in the callers — this helper + only issues the request. + """ + if agent.api_mode == "codex_responses": + request_client = make_client("codex_stream_request") + return agent._run_codex_stream( + api_kwargs, + client=request_client, + on_first_delta=getattr(agent, "_codex_on_first_delta", None), + ) + if agent.api_mode == "anthropic_messages": + return agent._anthropic_messages_create(api_kwargs) + if agent.api_mode == "bedrock_converse": + # Bedrock uses boto3 directly — no OpenAI client needed. + # normalize_converse_response produces an OpenAI-compatible + # SimpleNamespace so the rest of the agent loop can treat + # bedrock responses like chat_completions responses. + from agent.bedrock_adapter import ( + _get_bedrock_runtime_client, + invalidate_runtime_client, + is_stale_connection_error, + normalize_converse_response, + ) + region = api_kwargs.pop("__bedrock_region__", "us-east-1") + api_kwargs.pop("__bedrock_converse__", None) + client = _get_bedrock_runtime_client(region) + try: + raw_response = client.converse(**api_kwargs) + except Exception as _bedrock_exc: + # Evict the cached client on stale-connection failures + # so the outer retry loop builds a fresh client/pool. + if is_stale_connection_error(_bedrock_exc): + invalidate_runtime_client(region) + raise + return normalize_converse_response(raw_response) + if agent.provider == "moa": + # MoA is a virtual chat-completions provider backed by the + # in-process MoAClient facade. Do not rebuild a request-local + # OpenAI client from the virtual runtime metadata. + return agent.client.chat.completions.create(**api_kwargs) + request_client = make_client("chat_completion_request") + return request_client.chat.completions.create(**api_kwargs) + + +def should_use_direct_api_call(agent) -> bool: + """Whether a cron OpenAI-wire request should skip the interrupt worker. + + Issue #62151 is specific to OpenRouter's chat-completions path inside the + gateway cron thread stack. Keep native/Codex/Bedrock/MoA transports on their + established workers: their cancellation and client ownership differ, and + the report provides no evidence that those paths share the pre-HTTP wedge. + """ + return ( + getattr(agent, "platform", None) == "cron" + and getattr(agent, "api_mode", None) == "chat_completions" + and getattr(agent, "provider", None) != "moa" + ) + + +def direct_api_call(agent, api_kwargs: dict): + """Run a non-streaming LLM call inline on the conversation thread. + + Used when ``should_use_direct_api_call`` is True. Skips the interrupt worker + (whose only job is interactive-interrupt responsiveness, which this context + does not have) so the nested-pool deadlock (#62151) cannot occur. Because the + request runs in-flight normally, the per-request OpenAI client's own httpx + timeout (provider ``request_timeout_seconds`` / ``HERMES_API_TIMEOUT``) bounds + a genuinely hung provider — the same bound interactive calls already rely on. + """ + _check_stale_giveup(agent) + agent._touch_activity("waiting for non-streaming API response") + request_client_holder = {"client": None} + request_client_lock = threading.Lock() + + def _abort_active_request(reason: str) -> None: + """Abort the inline request from cron's watchdog/interrupt thread.""" + with request_client_lock: + request_client = request_client_holder["client"] + if request_client is not None: + agent._abort_request_openai_client(request_client, reason=reason) + + def _make_client(reason: str): + client = agent._create_request_openai_client(reason=reason, api_kwargs=api_kwargs) + with request_client_lock: + request_client_holder["client"] = client + agent._active_request_abort = _abort_active_request + return client + + try: + response = _dispatch_nonstreaming_api_request( + agent, api_kwargs, make_client=_make_client + ) + except Exception: + if getattr(agent, "_interrupt_requested", False): + raise InterruptedError("Agent interrupted during API call") from None + raise + else: + if getattr(agent, "_interrupt_requested", False): + raise InterruptedError("Agent interrupted during API call") + _reset_stale_streak(agent) + return response + finally: + if getattr(agent, "_active_request_abort", None) is _abort_active_request: + agent._active_request_abort = None + with request_client_lock: + request_client = request_client_holder["client"] + request_client_holder["client"] = None + if request_client is not None: + agent._close_request_openai_client(request_client, reason="request_complete") + + def interruptible_api_call(agent, api_kwargs: dict): """ Run the API call in a background thread so the main conversation loop @@ -231,6 +373,12 @@ def interruptible_api_call(agent, api_kwargs: dict): the main retry loop can try again with backoff / credential rotation / provider fallback. """ + # Cron and other non-interactive, nested-pool contexts must not spawn the + # interrupt worker — it wedges before the socket opens on the 2nd+ call + # (#62151). Run inline instead. See should_use_direct_api_call. + if should_use_direct_api_call(agent): + return direct_api_call(agent, api_kwargs) + result = {"response": None, "error": None} # Cross-turn stale-call circuit breaker (#58962) — non-streaming sibling @@ -294,56 +442,19 @@ def interruptible_api_call(agent, api_kwargs: dict): def _call(): try: - if agent.api_mode == "codex_responses": - request_client = _set_request_client( + # _set_request_client registers each per-request OpenAI client with + # the stranger-thread abort machinery above; the shared dispatch + # helper builds it via this callback so the interrupt / stale-call + # detectors can force-close the worker's connection. + result["response"] = _dispatch_nonstreaming_api_request( + agent, + api_kwargs, + make_client=lambda reason: _set_request_client( agent._create_request_openai_client( - reason="codex_stream_request", - api_kwargs=api_kwargs, + reason=reason, api_kwargs=api_kwargs ) - ) - result["response"] = agent._run_codex_stream( - api_kwargs, - client=request_client, - on_first_delta=getattr(agent, "_codex_on_first_delta", None), - ) - elif agent.api_mode == "anthropic_messages": - result["response"] = agent._anthropic_messages_create(api_kwargs) - elif agent.api_mode == "bedrock_converse": - # Bedrock uses boto3 directly — no OpenAI client needed. - # normalize_converse_response produces an OpenAI-compatible - # SimpleNamespace so the rest of the agent loop can treat - # bedrock responses like chat_completions responses. - from agent.bedrock_adapter import ( - _get_bedrock_runtime_client, - invalidate_runtime_client, - is_stale_connection_error, - normalize_converse_response, - ) - region = api_kwargs.pop("__bedrock_region__", "us-east-1") - api_kwargs.pop("__bedrock_converse__", None) - client = _get_bedrock_runtime_client(region) - try: - raw_response = client.converse(**api_kwargs) - except Exception as _bedrock_exc: - # Evict the cached client on stale-connection failures - # so the outer retry loop builds a fresh client/pool. - if is_stale_connection_error(_bedrock_exc): - invalidate_runtime_client(region) - raise - result["response"] = normalize_converse_response(raw_response) - elif agent.provider == "moa": - # MoA is a virtual chat-completions provider backed by the - # in-process MoAClient facade. Do not rebuild a request-local - # OpenAI client from the virtual runtime metadata. - result["response"] = agent.client.chat.completions.create(**api_kwargs) - else: - request_client = _set_request_client( - agent._create_request_openai_client( - reason="chat_completion_request", - api_kwargs=api_kwargs, - ) - ) - result["response"] = request_client.chat.completions.create(**api_kwargs) + ), + ) except Exception as e: # If the request was cancelled by the main thread's interrupt # handler, the transport error is the expected consequence of our @@ -801,21 +912,8 @@ def build_api_kwargs(agent, api_messages: list) -> dict: _omit_temp = False _fixed_temp = None - # Provider preferences (OpenRouter-style) - _prefs: Dict[str, Any] = {} - if agent.providers_allowed: - _prefs["only"] = agent.providers_allowed - if agent.providers_ignored: - _prefs["ignore"] = agent.providers_ignored - if agent.providers_order: - _prefs["order"] = agent.providers_order - _provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) - if _provider_sort: - _prefs["sort"] = _provider_sort - if agent.provider_require_parameters: - _prefs["require_parameters"] = True - if agent.provider_data_collection: - _prefs["data_collection"] = agent.provider_data_collection + # Provider preferences (aggregator profile decides whether to emit them). + _prefs = _provider_preferences_for_agent(agent) # Anthropic-compatible max-output fallback (last resort only — applied in # build_kwargs *after* ephemeral/user/profile max_tokens, never overriding @@ -1405,6 +1503,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool fb_api_mode = "bedrock_converse" old_model = agent.model + old_provider = agent.provider # Clear the per-config context_length override so the fallback # model's actual context window is resolved instead of inheriting @@ -1550,6 +1649,16 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool f"🔄 Primary model failed — switching to fallback: " f"{fb_model} via {fb_provider}" ) + # The buffered line above is dropped on successful recovery, but a + # provider/model switch is a durable state change operators must see + # even when the fallback succeeds. Record a one-shot notice that the + # success path surfaces exactly once via _emit_pending_fallback_notice + # (see run_agent.py); it is discarded on terminal failure since the + # buffered line is flushed instead. See fallback-observability fix. + agent._pending_fallback_notice = ( + f"🔄 Switched to fallback model: {old_model} via {old_provider} " + f"→ {fb_model} via {fb_provider}" + ) logger.info( "Fallback activated: %s → %s (%s)", old_model, fb_model, fb_provider, @@ -1685,18 +1794,28 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: if _lm_reasoning_effort is not None: summary_kwargs["reasoning_effort"] = _lm_reasoning_effort - # Include provider routing preferences - provider_preferences = {} - if agent.providers_allowed: - provider_preferences["only"] = agent.providers_allowed - if agent.providers_ignored: - provider_preferences["ignore"] = agent.providers_ignored - if agent.providers_order: - provider_preferences["order"] = agent.providers_order - _provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) - if _provider_sort: - provider_preferences["sort"] = _provider_sort - if provider_preferences and ( + # Merge the profile's canonical body even when routing is unset: + # profiles may always emit required metadata such as Portal tags. + provider_preferences = _provider_preferences_for_agent(agent) + profile_extra_body = {} + try: + from providers import get_provider_profile + + provider_profile = get_provider_profile(agent.provider) + if provider_profile is not None: + profile_extra_body = provider_profile.build_extra_body( + session_id=getattr(agent, "session_id", None), + provider_preferences=provider_preferences or None, + model=agent.model, + base_url=agent.base_url, + reasoning_config=agent.reasoning_config, + ) + except Exception: + pass + + if profile_extra_body: + summary_extra_body.update(profile_extra_body) + if provider_preferences and "provider" not in profile_extra_body and ( (agent.provider or "").strip().lower() == "openrouter" or agent._is_openrouter_url() ): @@ -1852,6 +1971,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= if agent._interrupt_requested: raise InterruptedError("Agent interrupted before streaming API call") + # Cron and other non-interactive, nested-pool contexts deadlock on the + # spawned worker thread (#62151). They also have no stream consumer, so the + # deltas this path produces go nowhere. Delegate to the non-streaming entry + # (which runs inline via should_use_direct_api_call) exactly like the codex + # branch below — routing through the _interruptible_api_call method keeps the + # outer loop's per-request retry/refresh seam intact. + if should_use_direct_api_call(agent): + return agent._interruptible_api_call(api_kwargs) + if agent.api_mode == "codex_responses": # Codex streams internally via _run_codex_stream. The main dispatch # in _interruptible_api_call already calls it; we just need to diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 4d138ce6e63..81c3131eace 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -288,6 +288,13 @@ _RESPONSES_BUILTIN_TOOL_TYPES = { _RESPONSE_MESSAGE_STATUSES = {"completed", "incomplete", "in_progress"} +# The Responses API rejects input[].id longer than this with a non-retryable +# HTTP 400 ("string too long"). Codex-issued assistant message ids are +# server-assigned base64 blobs that can run 400+ chars, while Hermes-minted +# ids (msg_...) stay well under this cap and are worth keeping for +# prefix-cache hits. Drop only the oversized ones on replay. +_MAX_RESPONSES_ITEM_ID_LENGTH = 64 + def _normalize_responses_message_status(value: Any, *, default: str = "completed") -> str: """Normalize a Responses assistant message status for replay. @@ -307,6 +314,7 @@ def _chat_messages_to_responses_input( messages: List[Dict[str, Any]], *, is_xai_responses: bool = False, + is_github_responses: bool = False, replay_encrypted_reasoning: bool = True, current_issuer_kind: Optional[str] = None, ) -> List[Dict[str, Any]]: @@ -331,6 +339,16 @@ def _chat_messages_to_responses_input( items from the conversation history and threads ``replay_enabled=False`` through this converter so subsequent turns send no reasoning items. + ``is_github_responses`` drops the ``id`` field from replayed + ``codex_message_items`` regardless of length. The Copilot backend + (api.githubcopilot.com/responses) binds these ids to a specific + backend "connection" — credential-pool rotation, a gateway restart, + or routine load-balancer churn between turns all invalidate it — and + rejects a stale id with HTTP 401 "input item ID does not belong to + this connection" even for short ids (see #32716). ``phase``/ + ``status``/``content`` are still replayed; only ``id`` is unsafe to + reuse across a Copilot connection. + ``current_issuer_kind`` enables a per-item cross-issuer guard. The Responses API's ``encrypted_content`` blob is decryptable only by the endpoint that minted it — replaying a Codex-issued blob against xAI @@ -463,8 +481,14 @@ def _chat_messages_to_responses_input( "content": normalized_content_parts, } item_id = raw_item.get("id") - if isinstance(item_id, str) and item_id.strip(): - replay_item["id"] = item_id.strip() + if ( + not is_github_responses + and isinstance(item_id, str) + and item_id.strip() + ): + stripped_id = item_id.strip() + if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH: + replay_item["id"] = stripped_id phase = raw_item.get("phase") if isinstance(phase, str) and phase.strip(): replay_item["phase"] = phase.strip() @@ -576,7 +600,11 @@ def _chat_messages_to_responses_input( # Input preflight / validation # --------------------------------------------------------------------------- -def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]: +def _preflight_codex_input_items( + raw_items: Any, + *, + is_github_responses: bool = False, +) -> List[Dict[str, Any]]: if not isinstance(raw_items, list): raise ValueError("Codex Responses input must be a list of input items.") @@ -717,8 +745,14 @@ def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]: "content": normalized_content, } item_id = item.get("id") - if isinstance(item_id, str) and item_id.strip(): - normalized_item["id"] = item_id.strip() + if ( + not is_github_responses + and isinstance(item_id, str) + and item_id.strip() + ): + stripped_id = item_id.strip() + if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH: + normalized_item["id"] = stripped_id phase = item.get("phase") if isinstance(phase, str) and phase.strip(): normalized_item["phase"] = phase.strip() @@ -790,6 +824,7 @@ def _preflight_codex_api_kwargs( api_kwargs: Any, *, allow_stream: bool = False, + is_github_responses: bool = False, ) -> Dict[str, Any]: if not isinstance(api_kwargs, dict): raise ValueError("Codex Responses request must be a dict.") @@ -811,7 +846,10 @@ def _preflight_codex_api_kwargs( instructions = str(instructions) instructions = instructions.strip() or DEFAULT_AGENT_IDENTITY - normalized_input = _preflight_codex_input_items(api_kwargs.get("input")) + normalized_input = _preflight_codex_input_items( + api_kwargs.get("input"), + is_github_responses=is_github_responses, + ) tools = api_kwargs.get("tools") normalized_tools = None diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 2077d2fddca..c1e46e8a460 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -113,6 +113,15 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]: usage = getattr(turn, "token_usage_last", None) if not isinstance(usage, dict) or not usage: + compressor = getattr(agent, "context_compressor", None) + if ( + compressor is not None + and getattr(compressor, "awaiting_real_usage_after_compression", False) + ): + # No usage means this turn cannot adjudicate the pending compaction. + # Consume the marker so a later unrelated reading is not charged to + # it and preflight deferral cannot stay latched indefinitely. + compressor.update_from_response({}) if agent._session_db and agent.session_id: try: if not agent._session_db_created: @@ -120,6 +129,9 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]: agent._session_db.update_token_counts( agent.session_id, model=agent.model, + billing_provider=agent.provider, + billing_base_url=agent.base_url, + billing_mode="subscription_included", api_call_count=1, ) except Exception as exc: @@ -267,6 +279,18 @@ def _record_codex_app_server_compaction( compressor, "compression_count", 0 ) + 1 compressor.last_compression_rough_tokens = approx_tokens or 0 + # The app server has already completed a real compaction boundary. Its + # usage update (when supplied) is therefore the same real-vs-real + # effectiveness verdict used by the normal compression path. + record_boundary = getattr( + type(compressor), "record_completed_compaction", None + ) + if callable(record_boundary): + # Codex owns this summary. A prior Hermes deterministic-fallback + # flag must not leak into the native boundary's quality verdict. + record_boundary(compressor, used_fallback=False) + elif hasattr(compressor, "_verify_compaction_cleared_threshold"): + compressor._verify_compaction_cleared_threshold = True if not getattr(turn, "token_usage_last", None): compressor.last_prompt_tokens = -1 compressor.last_completion_tokens = 0 diff --git a/agent/coding_context.py b/agent/coding_context.py index 00f6d996d47..db38ab3daa8 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -56,6 +56,7 @@ import logging import os import re import subprocess +import tempfile from dataclasses import dataclass from pathlib import Path from typing import Any, Optional @@ -412,10 +413,18 @@ def _marker_root(cwd: Path) -> Optional[Path]: """ current = cwd.resolve() home = _home() + # Shared world-writable temp roots are never project roots: a stray + # manifest in /tmp (left by any process) must not flip every session + # whose cwd lives under the temp dir into the coding posture. Same + # reasoning as the $HOME skip below. + try: + temp_root = Path(tempfile.gettempdir()).resolve() + except Exception: + temp_root = None for depth, parent in enumerate([current, *current.parents]): if depth > 6: break - if parent == home: + if parent == home or (temp_root is not None and parent == temp_root): continue for marker in _PROJECT_MARKERS: if (parent / marker).exists(): diff --git a/agent/context_compressor.py b/agent/context_compressor.py index d952470fac2..ec4314ab40b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -736,6 +736,9 @@ class ContextCompressor(ContextEngine): self._last_aux_model_failure_model = None self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 + self._fallback_compression_streak = 0 + self._verify_compaction_cleared_threshold = False + self._last_compression_made_progress = False self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session self._last_summary_error = None self._last_compress_aborted = False @@ -771,6 +774,9 @@ class ContextCompressor(ContextEngine): self._last_aux_model_failure_model = None self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 + self._fallback_compression_streak = 0 + self._verify_compaction_cleared_threshold = False + self._last_compression_made_progress = False self._summary_failure_cooldown_until = 0.0 self._last_compress_aborted = False self._context_probed = False @@ -786,12 +792,84 @@ class ContextCompressor(ContextEngine): self._session_id = session_id or "" self._summary_failure_cooldown_until = 0.0 self._last_summary_error = None + self._fallback_compression_streak = 0 self.get_active_compression_failure_cooldown() + self._load_fallback_compression_streak() def on_session_start(self, session_id: str, **kwargs) -> None: """Bind session-scoped compression state for a new or resumed session.""" super().on_session_start(session_id, **kwargs) - self.bind_session_state(kwargs.get("session_db", getattr(self, "_session_db", None)), session_id) + boundary_reason = kwargs.get("boundary_reason") + old_session_id = kwargs.get("old_session_id") + session_db = kwargs.get("session_db", getattr(self, "_session_db", None)) + previous_fallback_streak = self._fallback_compression_streak + if boundary_reason == "compression" and old_session_id: + getter = getattr(session_db, "get_compression_fallback_streak", None) + if callable(getter): + try: + stored_streak = getter(old_session_id) + if isinstance(stored_streak, (int, float, str)): + previous_fallback_streak = max(0, int(stored_streak)) + except (TypeError, ValueError, sqlite3.Error) as exc: + logger.debug("compression parent fallback streak lookup failed: %s", exc) + except Exception as exc: + logger.debug( + "compression parent fallback streak lookup failed (non-sqlite): %s", + exc, + ) + self.bind_session_state(session_db, session_id) + if boundary_reason == "compression": + # Rotation creates a fresh child row before this callback. Preserve + # the logical conversation's streak until boundary bookkeeping + # persists the updated value onto the child row. + self._fallback_compression_streak = previous_fallback_streak + + def _load_fallback_compression_streak(self) -> None: + session_db = getattr(self, "_session_db", None) + session_id = getattr(self, "_session_id", "") + getter = getattr(session_db, "get_compression_fallback_streak", None) + if not session_id or not callable(getter): + return + try: + stored_streak = getter(session_id) + self._fallback_compression_streak = max( + 0, + int(stored_streak) + if isinstance(stored_streak, (int, float, str)) + else 0, + ) + except (TypeError, ValueError, sqlite3.Error) as exc: + logger.debug("compression fallback streak lookup failed: %s", exc) + except Exception as exc: + logger.debug("compression fallback streak lookup failed (non-sqlite): %s", exc) + + def _persist_fallback_compression_streak(self) -> None: + session_db = getattr(self, "_session_db", None) + session_id = getattr(self, "_session_id", "") + setter = getattr(session_db, "set_compression_fallback_streak", None) + if not session_id or not callable(setter): + return + try: + setter(session_id, self._fallback_compression_streak) + except sqlite3.Error as exc: + logger.debug("compression fallback streak persist failed: %s", exc) + except Exception as exc: + logger.debug("compression fallback streak persist failed (non-sqlite): %s", exc) + + def record_completed_compaction(self, *, used_fallback: bool = False) -> None: + """Record one completed boundary and its summary quality.""" + self._verify_compaction_cleared_threshold = True + if used_fallback: + self._fallback_compression_streak += 1 + if not self.quiet_mode: + logger.warning( + "Compaction completed with a deterministic fallback summary. " + "fallback_compression_streak=%d", + self._fallback_compression_streak, + ) + elif self._fallback_compression_streak: + self._fallback_compression_streak = 0 + self._persist_fallback_compression_streak() def get_active_compression_failure_cooldown(self) -> Optional[Dict[str, Any]]: """Return the live compression-failure cooldown for the bound session.""" @@ -889,6 +967,12 @@ class ContextCompressor(ContextEngine): max_tokens: int | None = None, ) -> None: """Update model info after a model switch or fallback activation.""" + runtime_changed = any(( + model != self.model, + provider != self.provider, + base_url != self.base_url, + api_mode != self.api_mode, + )) self.model = model self.base_url = base_url self.api_key = api_key @@ -944,6 +1028,11 @@ class ContextCompressor(ContextEngine): self.last_compression_rough_tokens = 0 self.awaiting_real_usage_after_compression = False self._ineffective_compression_count = 0 + if runtime_changed: + self._fallback_compression_streak = 0 + self._persist_fallback_compression_streak() + self._verify_compaction_cleared_threshold = False + self._last_compression_made_progress = False # When the MINIMUM_CONTEXT_LENGTH floor meets/exceeds a small context # window, compacting at the percentage (50% → 32K of a 64K window) wastes @@ -1131,6 +1220,16 @@ class ContextCompressor(ContextEngine): # Anti-thrashing: track whether last compression was effective self._last_compression_savings_pct: float = 100.0 self._ineffective_compression_count: int = 0 + # Consecutive completed deterministic-fallback boundaries. Unlike the + # real-usage effectiveness counter, ordinary fitting responses must not + # reset this breaker; only a healthy completed summary does. + self._fallback_compression_streak: int = 0 + # Set after a completed compression boundary; consumed by the next + # provider-reported prompt count in update_from_response(). + self._verify_compaction_cleared_threshold: bool = False + # Lets the boundary wrapper distinguish a completed rewrite from a + # no-op/abort without inferring progress from message-list length. + self._last_compression_made_progress: bool = False self._summary_failure_cooldown_until: float = 0.0 self._last_summary_error: Optional[str] = None # When summary generation fails and a static fallback is inserted, @@ -1177,8 +1276,50 @@ class ContextCompressor(ContextEngine): if self.last_prompt_tokens < self.threshold_tokens: if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0: self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens + # Any real provider reading below the trigger proves the prompt + # fits again. Clear the real-usage effectiveness latch even + # when this response was not immediately after compaction. The + # independent fallback streak is boundary-scoped and survives + # ordinary fitting responses during context regrowth. + self._ineffective_compression_count = 0 else: self.last_rough_tokens_when_real_prompt_fit = 0 + + # Anti-thrashing verdict, judged HERE because this is the only place + # that sees the provider's real prompt count for the just-compacted + # conversation. Effectiveness is "did the prompt get under the + # threshold?", not "did the message list shrink?": compaction can + # only shrink messages, while the system prompt and tool schemas are + # an incompressible floor (with 50+ tools, 20-30K tokens — see + # #14695). When that floor alone meets the threshold, every pass + # shrinks messages by a healthy margin yet leaves the prompt over the + # line, so the next turn compacts again, forever. + # + # It must NOT live in should_compress(): that runs twice per turn + # with two different measures (a rough preflight estimate and the + # real post-response count, #36718), and the rough one can dip below + # the threshold and reset the strike every turn, re-opening the loop. + # Keying on real usage compares like with like and fires exactly once + # per compaction. + if self._verify_compaction_cleared_threshold: + if self.last_prompt_tokens >= self.threshold_tokens: + self._ineffective_compression_count += 1 + if not self.quiet_mode: + logger.warning( + "Compaction did not clear the threshold: %d real " + "tokens still >= %d. The incompressible prompt " + "(system prompt + tool schemas) may already exceed " + "it, in which case shrinking messages cannot help. " + "ineffective_compression_count=%d", + self.last_prompt_tokens, self.threshold_tokens, + self._ineffective_compression_count, + ) + else: + self._ineffective_compression_count = 0 + # Consume the pending-verification flag once real usage arrives, whether + # or not prompt_tokens was reported, so a usage-less response can't leave + # it armed for a later, unrelated reading. + self._verify_compaction_cleared_threshold = False self.awaiting_real_usage_after_compression = False def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool: @@ -1233,6 +1374,10 @@ class ContextCompressor(ContextEngine): tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens if tokens < self.threshold_tokens: return False + return not self._automatic_compression_blocked() + + def _automatic_compression_blocked(self) -> bool: + """Return whether automatic compaction is in cooldown or tripped.""" # Do not trigger compression while the summary LLM is in cooldown. # On a 429/transient failure _generate_summary() sets a cooldown and # returns None; compress() then inserts a static fallback marker and @@ -1249,18 +1394,23 @@ class ContextCompressor(ContextEngine): "Compression deferred — summary LLM in cooldown for %.0fs more", _cooldown_remaining, ) - return False + return True # Anti-thrashing: back off if recent compressions were ineffective - if self._ineffective_compression_count >= 2: + if ( + self._ineffective_compression_count >= 2 + or self._fallback_compression_streak >= 2 + ): if not self.quiet_mode: logger.warning( - "Compression skipped — last %d compressions saved <10%% each. " - "Consider /new to start a fresh session, or /compress " - "for focused compression.", + "Compression skipped — repeated compaction attempts did not " + "restore healthy context. ineffective=%d fallback=%d. " + "Consider /new to start fresh, or /compress for " + "focused compression.", self._ineffective_compression_count, + self._fallback_compression_streak, ) - return False - return True + return True + return False # ------------------------------------------------------------------ # Tool output pruning (cheap pre-pass, no LLM call) @@ -2820,6 +2970,7 @@ This compaction should PRIORITISE preserving all information related to the focu self._last_aux_model_failure_error = None self._last_aux_model_failure_model = None self._last_compress_aborted = False + self._last_compression_made_progress = False # NOTE: do NOT reset _last_summary_auth_failure or # _last_summary_network_failure here. These flags are set by # _generate_summary() on a terminal failure and are already cleared on @@ -2840,10 +2991,21 @@ This compaction should PRIORITISE preserving all information related to the focu # Only need head + 3 tail messages minimum (token budget decides the real tail size) _min_for_compress = self._protect_head_size(messages) + 3 + 1 if n_messages <= _min_for_compress: + # Record the no-op, exactly as the sibling "no compressable window" + # branch below does (#40803). Returning without touching the + # anti-thrashing counter leaves should_compress() saying True on a + # transcript that can never shrink: when the prompt sits above the + # threshold because of the incompressible floor (system prompt + + # tool schemas), every subsequent turn re-fires a compaction that + # returns here unchanged, and the CLI appears frozen. + self._ineffective_compression_count += 1 + self._last_compression_savings_pct = 0.0 if not self.quiet_mode: logger.warning( - "Cannot compress: only %d messages (need > %d)", + "Cannot compress: only %d messages (need > %d). " + "ineffective_compression_count=%d", n_messages, _min_for_compress, + self._ineffective_compression_count, ) return messages @@ -3139,15 +3301,26 @@ This compaction should PRIORITISE preserving all information related to the focu compressed = _strip_historical_media(compressed) new_estimate = estimate_messages_tokens_rough(compressed) - saved_estimate = display_tokens - new_estimate - # Anti-thrashing: track compression effectiveness - savings_pct = (saved_estimate / display_tokens * 100) if display_tokens > 0 else 0 + # Anti-thrashing: measure effectiveness on a like-for-like basis. + # + # ``display_tokens`` is usually ``current_tokens`` — the provider's real + # prompt count, which includes the system prompt and tool schemas. + # ``new_estimate`` covers the messages ONLY. Comparing the two makes a + # compaction that freed almost nothing look like it saved ~96%, so the + # counter below resets every pass and the anti-thrashing guard is dead + # code. Compaction can only shrink messages, so score it against the + # messages it was given. + pre_estimate = estimate_messages_tokens_rough(messages) + saved_estimate = pre_estimate - new_estimate + savings_pct = (saved_estimate / pre_estimate * 100) if pre_estimate > 0 else 0 self._last_compression_savings_pct = savings_pct - if savings_pct < 10: - self._ineffective_compression_count += 1 - else: - self._ineffective_compression_count = 0 + + # Message-only savings are diagnostic. The anti-thrashing verdict is + # owned by the next provider-reported prompt count, which answers the + # actual question: did this completed boundary get under the threshold? + # Counting a low message-savings estimate here as well would give one + # compaction two strikes when that real reading remains over threshold. if not self.quiet_mode: logger.info( @@ -3164,5 +3337,6 @@ This compaction should PRIORITISE preserving all information related to the focu # are positional; this single terminal sweep makes it structural so a # future copy site cannot re-leak the marker into the child-session flush. _strip_persistence_markers(compressed) + self._last_compression_made_progress = True return compressed diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 843960cc281..6f2c5eb5757 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -28,6 +28,7 @@ these paths see no behavioural change. from __future__ import annotations +import inspect import logging import os import tempfile @@ -52,6 +53,29 @@ COMPACTION_STATUS = ( ) +def _lock_api_is_absent_on_session_db(lock_db: Any) -> bool: + """Whether the live in-memory SessionDB class structurally predates locks. + + In the supported hot-reload skew, this module is new while the already + imported ``hermes_state.SessionDB`` class (and its live instances) is old. + Only that exact class identity may fail open. Proxies, nominal lookalikes, + non-callables, and descriptor failures must fail closed. Static lookup + avoids invoking a present-but-broken descriptor. + """ + try: + from hermes_state import SessionDB + + missing = object() + return ( + type(lock_db) is SessionDB + and inspect.getattr_static( + SessionDB, "try_acquire_compression_lock", missing + ) is missing + ) + except Exception: + return False + + def _compression_lock_holder(agent: Any) -> str: """Build a unique holder id for the lock: pid:tid:agent-instance:uuid. @@ -480,6 +504,21 @@ def compress_context( force=force, ) + # Every automatic entrypoint must honor compressor-owned cooldown and + # breaker state. Gateway hygiene constructs a fresh AIAgent, so the + # persisted fallback streak is loaded by bind_session_state() before this. + if not force: + blocked = getattr( + type(agent.context_compressor), + "_automatic_compression_blocked", + None, + ) + if callable(blocked) and blocked(agent.context_compressor): + existing_prompt = getattr(agent, "_cached_system_prompt", None) + if not existing_prompt: + existing_prompt = agent._build_system_prompt(system_message) + return messages, existing_prompt + # Lazy feasibility check — run the auxiliary-provider probe + context # length lookup just-in-time on the first compression attempt instead of # at AIAgent.__init__. Saves ~400ms cold off every short session that @@ -541,21 +580,31 @@ def compress_context( _lock_sid = agent.session_id or "" _lock_holder: Optional[str] = None # Probe whether the lock subsystem is actually available on this - # SessionDB instance. A process running mismatched module versions - # (e.g. ``conversation_compression.py`` reloaded after a pull but the - # long-lived ``hermes_state.SessionDB`` class still bound to the - # pre-#34351 version in memory) has the call site but not the method. - # In that case ``try_acquire_compression_lock`` raises AttributeError — - # NOT a ``sqlite3.Error`` — so the method's own fail-open guard never - # runs and the exception propagates to the outer agent loop, which - # prints the error and retries. Because compression never succeeds, - # the token count never drops and the loop re-triggers compaction - # forever (the "API call #47/#48/#49 ... has no attribute - # try_acquire_compression_lock" spin). Fail OPEN here: if the lock - # subsystem is missing or broken in any unexpected way, skip locking - # and proceed with compression. Skipping the lock risks a rare - # concurrent-compression session fork; an infinite no-progress loop - # that never compresses at all is strictly worse. + # SessionDB instance. A process running mismatched module versions can have + # this call site while its long-lived SessionDB instance predates the lock + # API. Only that structural absence is safe to fail open for: compression + # must make progress rather than spin forever after an update. Once the + # method has been resolved, every exception from its implementation fails + # closed because proceeding without a lock can fork the session lineage. + _try_acquire_lock = None + _lock_lookup_error: Optional[Exception] = None + _legacy_session_db_without_lock_api = False + if _lock_db is not None: + try: + _legacy_session_db_without_lock_api = _lock_api_is_absent_on_session_db( + _lock_db + ) + except Exception as exc: + _lock_lookup_error = exc + if _lock_lookup_error is None and not _legacy_session_db_without_lock_api: + try: + _try_acquire_lock = _lock_db.try_acquire_compression_lock + if not callable(_try_acquire_lock): + _lock_lookup_error = TypeError( + "compression lock API is present but not callable" + ) + except Exception as exc: + _lock_lookup_error = exc try: _lock_ttl = float(getattr(agent, "_compression_lock_ttl_seconds", 300.0) or 300.0) except (TypeError, ValueError): @@ -564,25 +613,58 @@ def compress_context( _lock_refresher: Optional[_CompressionLockLeaseRefresher] = None if _lock_db is not None and _lock_sid: _lock_holder = _compression_lock_holder(agent) - try: - _lock_acquired = _lock_db.try_acquire_compression_lock( - _lock_sid, _lock_holder, ttl_seconds=_lock_ttl + if _lock_lookup_error is not None: + # Attribute lookup itself failed for a reason other than a missing + # lock API. It is unsafe to proceed without a lock in that case. + _lock_holder = None + logger.warning( + "compression lock lookup raised unexpectedly for session=%s " + "(%s: %s) — skipping compression this cycle", + _lock_sid, type(_lock_lookup_error).__name__, _lock_lookup_error, ) - except Exception as _lock_err: - # Broken/absent lock subsystem (version skew, etc.). Log once - # per session and proceed WITHOUT the lock rather than letting - # the exception spin the outer loop. - _lock_holder = None # we don't own anything to release + _lock_acquired = False + elif _try_acquire_lock is None: + # The lock API itself is absent on this in-memory instance. Log once + # and proceed unlocked so an update-version skew cannot leave the + # outer auto-compression loop making no progress forever. + _lock_holder = None if getattr(agent, "_last_compression_lock_error_sid", None) != _lock_sid: agent._last_compression_lock_error_sid = _lock_sid logger.warning( "compression lock subsystem unavailable for session=%s " - "(%s: %s) — proceeding without lock. This usually means a " - "stale in-memory module after an update; restart the " - "process (or `hermes update`) to resync.", + "— proceeding without lock. This usually means a stale " + "in-memory module after an update; restart the process " + "(or `hermes update`) to resync.", + _lock_sid, + ) + _lock_acquired = True # acquired-but-unlocked compatibility path + else: + try: + _lock_acquired = _try_acquire_lock( + _lock_sid, _lock_holder, ttl_seconds=_lock_ttl + ) + except Exception as _lock_err: + # The method exists and entered its implementation but failed. + # Do not mistake an internal AttributeError or TypeError for + # version skew: fail closed and preserve session lineage. A + # failure after SQLite committed the acquire can leave our + # holder row behind, so release it best-effort before returning + # unchanged messages; release is holder-qualified and safe when + # acquisition never succeeded. + try: + _lock_db.release_compression_lock(_lock_sid, _lock_holder) + except Exception as _release_err: + logger.debug( + "compression lock cleanup after failed acquire failed: %s", + _release_err, + ) + _lock_holder = None + logger.warning( + "compression lock acquisition raised unexpectedly for " + "session=%s (%s: %s) — skipping compression this cycle", _lock_sid, type(_lock_err).__name__, _lock_err, ) - _lock_acquired = True # treat as acquired-but-unlocked; proceed + _lock_acquired = False if not _lock_acquired: try: existing = _lock_db.get_compression_lock_holder(_lock_sid) @@ -651,6 +733,17 @@ def compress_context( _release_lock() raise + # Capture boundary quality before session-rotation callbacks run. Built-in + # and plugin lifecycle hooks may reset per-session compressor fields while + # rebinding to the child id; the completed attempt's verdict must survive + # that rebind and be recorded only after the full boundary commits. + _compression_made_progress = bool( + getattr(agent.context_compressor, "_last_compression_made_progress", False) + ) + _compression_used_fallback = bool( + getattr(agent.context_compressor, "_last_summary_fallback_used", False) + ) + # If compression aborted (aux LLM failed to produce a usable summary) # the compressor returns the input messages unchanged. Surface the # error to the user, skip the session-rotation work entirely (no @@ -673,6 +766,20 @@ def compress_context( finally: _release_lock() + # A compressor that returns the exact input object made no structural + # progress. Do not rotate/rewrite the session or arm post-compression + # deferral in that case; its own anti-thrash counter records the no-op. + if compressed is messages: + logger.info( + "Compression made no progress (session=%s) — skipping boundary rewrite.", + agent.session_id or "none", + ) + _existing_sp = getattr(agent, "_cached_system_prompt", None) + if not _existing_sp: + _existing_sp = agent._build_system_prompt(system_message) + _release_lock() + return messages, _existing_sp + try: summary_error = getattr(agent.context_compressor, "_last_summary_error", None) if summary_error: @@ -961,6 +1068,23 @@ def compress_context( agent.context_compressor.last_prompt_tokens = -1 agent.context_compressor.last_completion_tokens = 0 agent.context_compressor.awaiting_real_usage_after_compression = True + # Arm the effectiveness verdict only after a completed rewrite crosses + # the full compaction boundary. Exceptions, aborts, and no-op attempts + # leave this false, so unrelated later usage cannot be charged to an + # attempt that never changed the transcript. + if _compression_made_progress: + record_boundary = getattr( + type(agent.context_compressor), + "record_completed_compaction", + None, + ) + if callable(record_boundary): + record_boundary( + agent.context_compressor, + used_fallback=_compression_used_fallback, + ) + else: + agent.context_compressor._verify_compaction_cleared_threshold = True # Clear the file-read dedup cache. After compression the original # read content is summarised away — if the model re-reads the same @@ -1054,7 +1178,7 @@ def _compress_context_via_codex_app_server( pass agent._codex_session = None - if getattr(result, "error", None): + if getattr(result, "interrupted", False) or getattr(result, "error", None): try: agent._emit_warning( f"⚠ Codex app-server compaction failed: {result.error}" @@ -1078,7 +1202,11 @@ def _compress_context_via_codex_app_server( approx_tokens=approx_tokens, force=True, ) - if getattr(result, "token_usage_last", None): + # An empty usage report must consume the pending post-compaction verdict + # rather than leaving preflight deferral armed until some unrelated later + # Codex turn supplies usage. Minimal external test engines may not expose + # the ContextEngine update hook; preserve their existing bookkeeping. + if hasattr(agent.context_compressor, "update_from_response"): _record_codex_app_server_usage(agent, result) except Exception: logger.debug("codex compaction bookkeeping failed", exc_info=True) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index cbcb8561701..12c80a8bfb7 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -613,6 +613,11 @@ def run_conversation( truncated_response_parts: List[str] = [] compression_attempts = 0 _turn_exit_reason = "unknown" # Diagnostic: why the loop ended + # Last composed answer intentionally held back by a verification gate. If + # that continuation consumes the remaining budget, this is the best + # user-facing result available; it must not be confused with error or + # recovery text produced by unrelated exit paths. + _pending_verification_response = None # Per-turn tally of consecutive successful credential-pool token refreshes, # keyed by (provider, pool-entry-id). A persistent upstream 401 lets @@ -1162,7 +1167,11 @@ def run_conversation( if agent._force_ascii_payload: _sanitize_structure_non_ascii(api_kwargs) if agent.api_mode == "codex_responses": - api_kwargs = agent._get_transport().preflight_kwargs(api_kwargs, allow_stream=False) + api_kwargs = agent._get_transport().preflight_kwargs( + api_kwargs, + allow_stream=False, + is_github_responses=agent._is_copilot_url(), + ) # Copilot x-initiator: the first API call of a user turn is # marked "user" so Copilot bills a premium request; tool-loop # follow-ups keep the default "agent" header (#3040). @@ -1309,6 +1318,12 @@ def run_conversation( _use_streaming = False def _perform_api_call(next_api_kwargs): + if agent.api_mode == "codex_responses": + next_api_kwargs = agent._get_transport().preflight_kwargs( + next_api_kwargs, + allow_stream=False, + is_github_responses=agent._is_copilot_url(), + ) if _use_streaming: return agent._interruptible_streaming_api_call( next_api_kwargs, on_first_delta=_stop_spinner @@ -2104,7 +2119,19 @@ def run_conversation( "reasoning_tokens": canonical_usage.reasoning_tokens, } agent.context_compressor.update_from_response(usage_dict) + elif getattr( + agent.context_compressor, + "awaiting_real_usage_after_compression", + False, + ): + # A response with no usage cannot adjudicate whether the + # prior compaction cleared the threshold. Consume the pending + # verdict now so a much later, unrelated reading is not + # charged to that old compaction, and so preflight deferral + # does not remain latched indefinitely. + agent.context_compressor.update_from_response({}) + if hasattr(response, 'usage') and response.usage: # Cache discovered context length after successful call. # Only persist limits confirmed by the provider (parsed # from the error message), not guessed probe tiers. @@ -3034,14 +3061,24 @@ def run_conversation( # (``/new``), switch to a larger-context model, or reduce # attachments. Forced compaction via ``/compress`` # (``force=True``) is unaffected — it never reaches this loop. + # + # Output-cap errors (max_tokens too large) are NOT input + # overflow — the recovery is a max_tokens-only retry that + # does not require compression. Exempt them from this guard + # so the retry still fires even when compression is disabled. _overflow_reasons = { FailoverReason.long_context_tier, FailoverReason.payload_too_large, FailoverReason.context_overflow, } + _is_output_cap_error = ( + is_output_cap_error(error_msg) + or parse_available_output_tokens_from_error(error_msg) is not None + ) if ( classified.reason in _overflow_reasons and not getattr(agent, "compression_enabled", True) + and not _is_output_cap_error ): agent._flush_status_buffer() agent._vprint( @@ -3445,15 +3482,33 @@ def run_conversation( # context_length = total window (input + output combined). available_out = parse_available_output_tokens_from_error(error_msg) if available_out is not None: - # Error is purely about the output cap being too large. - # Cap output to the available space and retry without - # touching context_length or triggering compression. - safe_out = max(1, available_out - 64) # small safety margin + # This is an output-cap error, not input overflow. + # The provider's available_tokens is the authoritative + # cap for the failed request, so keep it as an upper + # bound. Also estimate the current API request shape + # (system prompt, injected context, tool schemas) because + # Hermes may add API-only content not present in persisted + # messages. Use the smaller budget and apply a small + # safety margin. Do not alter context_length. + request_input_estimate = estimate_request_tokens_rough( + api_messages, tools=agent.tools or None, + ) + local_available_out = old_ctx - request_input_estimate + if local_available_out > 0: + safe_out = max(1, min(available_out, local_available_out) - 64) + else: + # The rough local estimate can overshoot the real + # request size. Fall back to the provider-reported + # budget, which is authoritative for the failed + # request. + safe_out = max(1, available_out - 64) agent._ephemeral_max_output_tokens = safe_out agent._buffer_vprint( f"⚠️ Output cap too large for current prompt — " f"retrying with max_tokens={safe_out:,} " - f"(available_tokens={available_out:,}; context_length unchanged at {old_ctx:,})" + f"(provider_available={available_out:,}, " + f"estimated_request_tokens={request_input_estimate:,}; " + f"context_length unchanged at {old_ctx:,})" ) # Still count against compression_attempts so we don't # loop forever if the error keeps recurring. @@ -5062,8 +5117,11 @@ def run_conversation( # Reset retry counter/signature on successful content agent._empty_content_retries = 0 agent._thinking_prefill_retries = 0 - # Successful content reached — drop any buffered retry - # status from earlier failed attempts in this turn. + # Successful content reached — surface the one-shot fallback + # switch notice (if a fallback activated this turn) before + # dropping the noisy retry buffer, so a provider/model switch + # stays visible even when the fallback succeeds. + agent._emit_pending_fallback_notice() agent._clear_status_buffer() from agent.agent_runtime_helpers import ( @@ -5096,6 +5154,10 @@ def run_conversation( } messages.append(continue_msg) agent._session_messages = messages + # An acknowledgment is explicitly non-final. Do not let its + # text suppress iteration-limit summarization if this + # continuation consumes the remaining budget. + final_response = None continue codex_ack_continuations = 0 @@ -5170,6 +5232,12 @@ def run_conversation( # terminal. Keep a debug breadcrumb in agent.log for tracing. logger.debug("verification stop-loop nudge issued (attempt %d)", agent._verification_stop_nudges) + # Keep the attempted answer only as an explicit fallback for + # continuation-budget exhaustion. ``final_response`` itself + # must be cleared so the finalizer can distinguish this gate + # from unrelated error/recovery exits. (#61631) + _pending_verification_response = final_response + final_response = None continue # User verification-loop gate: when the agent edited code this @@ -5221,6 +5289,8 @@ def run_conversation( agent._session_messages = messages logger.debug("pre_verify nudge issued (attempt %d)", agent._pre_verify_nudges) + _pending_verification_response = final_response + final_response = None continue messages.append(final_msg) @@ -5305,6 +5375,7 @@ def run_conversation( original_user_message=original_user_message, _should_review_memory=_should_review_memory, _turn_exit_reason=_turn_exit_reason, + _pending_verification_response=_pending_verification_response, ) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 9d5d81b2386..3bd59493508 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -128,6 +128,17 @@ _EXTRA_KEYS = frozenset({ }) +def _normalize_pool_auth_type(provider: str, token: Any, auth_type: Any) -> str: + """Infer pool auth metadata for token formats with one unambiguous meaning.""" + if ( + provider == "anthropic" + and isinstance(token, str) + and token.startswith("sk-ant-oat") + ): + return AUTH_TYPE_OAUTH + return str(auth_type or AUTH_TYPE_API_KEY) + + @dataclass class PooledCredential: provider: str @@ -157,6 +168,11 @@ class PooledCredential: def __post_init__(self): if self.extra is None: self.extra = {} + self.auth_type = _normalize_pool_auth_type( + self.provider, + self.access_token, + self.auth_type, + ) def __getattr__(self, name: str): if name in _EXTRA_KEYS: @@ -445,6 +461,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:``. 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 @@ -1378,6 +1432,11 @@ class CredentialPool: entries_to_prune: List[str] = [] available: List[PooledCredential] = [] for entry in self._entries: + # Borrowed credentials persist as metadata-only references and are + # hydrated from their live source on load. A stale duplicate row + # can remain unhydrated; never lease or select it as an empty key. + if entry.auth_type == AUTH_TYPE_API_KEY and not entry.runtime_api_key: + continue # For anthropic claude_code entries, sync from the credentials file # before any status/refresh checks. This picks up tokens refreshed # by other processes (Claude Code CLI, other Hermes profiles). @@ -1694,11 +1753,15 @@ class CredentialPool: def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool: - existing_idx = None + matching_indices = [] for idx, entry in enumerate(entries): if entry.source == source: - existing_idx = idx - break + matching_indices.append(idx) + + existing_idx = matching_indices[0] if matching_indices else None + duplicate_indices = set(matching_indices[1:]) + if duplicate_indices: + entries[:] = [entry for idx, entry in enumerate(entries) if idx not in duplicate_indices] if existing_idx is None: payload.setdefault("id", uuid.uuid4().hex[:6]) @@ -1730,8 +1793,8 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p # Runtime-only borrowed secret updates should refresh the in-memory # entry without forcing auth.json churn when the disk-safe payload is # unchanged (for example env keys with the same fingerprint). - return existing.to_dict() != updated.to_dict() - return False + return bool(duplicate_indices) or existing.to_dict() != updated.to_dict() + return bool(duplicate_indices) def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -> bool: @@ -2205,12 +2268,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool if _is_source_suppressed(provider, source): continue active_sources.add(source) - # Claude Code OAuth tokens are the only Anthropic credentials that should flow into the OAuth refresh path. - auth_type = ( - AUTH_TYPE_OAUTH - if provider == "anthropic" and token.startswith("sk-ant-oat") - else AUTH_TYPE_API_KEY - ) base_url = env_url or pconfig.inference_base_url if provider == "kimi-coding": base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url) @@ -2225,7 +2282,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool env_var=env_var, token=token, base_url=base_url, - auth_type=auth_type, ), ) return changed, active_sources @@ -2353,16 +2409,37 @@ def load_pool(provider: str) -> CredentialPool: for payload in raw_entries ) entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries] + raw_needs_auth_normalization = any( + isinstance(payload, dict) + and _normalize_pool_auth_type( + provider, + payload.get("access_token"), + payload.get("auth_type", AUTH_TYPE_API_KEY), + ) != payload.get("auth_type", AUTH_TYPE_API_KEY) + for payload in raw_entries + ) + if raw_needs_auth_normalization: + # A profile may be reading this provider from the global-root fallback. + # Keep that fallback read-only: only the store that owns these rows may + # rewrite them. Loading the default/root profile will heal global rows. + active_pool = _load_auth_store().get("credential_pool") + active_entries = active_pool.get(provider) if isinstance(active_pool, dict) else None + raw_needs_auth_normalization = bool(active_entries) if provider.startswith(CUSTOM_POOL_PREFIX): # Custom endpoint pool — seed from custom_providers config and model config custom_changed, custom_sources = _seed_custom_pool(provider, entries) - changed = raw_needs_sanitization or custom_changed + changed = raw_needs_sanitization or raw_needs_auth_normalization or custom_changed changed |= _prune_stale_seeded_entries(entries, custom_sources) else: singleton_changed, singleton_sources = _seed_from_singletons(provider, entries) env_changed, env_sources = _seed_from_env(provider, entries) - changed = raw_needs_sanitization or singleton_changed or env_changed + changed = ( + raw_needs_sanitization + or raw_needs_auth_normalization + or singleton_changed + or env_changed + ) # ``load_pool()`` is a non-destructive read for env-seeded entries: a # process missing a provider env var must not delete the persisted # pool entry for every other process (#9331). File-backed singletons diff --git a/agent/curator.py b/agent/curator.py index c13a36ecbbd..ada93248e24 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -45,12 +45,26 @@ def _strip_aux_credential(value: Any) -> Optional[str]: class _ReviewRuntimeBinding(NamedTuple): - """Provider/model for the curator review fork plus optional per-slot overrides.""" + """Provider/model for the curator review fork plus per-slot overrides.""" provider: str model: str explicit_api_key: Optional[str] explicit_base_url: Optional[str] + request_overrides: Dict[str, Any] + + +def _merge_request_overrides( + runtime_overrides: Any, + slot_extra_body: Any, +) -> Dict[str, Any]: + """Merge resolver metadata with task-local request body fields.""" + merged = dict(runtime_overrides or {}) + if isinstance(slot_extra_body, dict) and slot_extra_body: + extra_body = dict(merged.get("extra_body") or {}) + extra_body.update(slot_extra_body) + merged["extra_body"] = extra_body + return merged DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days @@ -1764,6 +1778,7 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding: _task_model, _strip_aux_credential(_cur_task.get("api_key")), _strip_aux_credential(_cur_task.get("base_url")), + _merge_request_overrides({}, _cur_task.get("extra_body")), ) # 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification) @@ -1781,10 +1796,11 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding: str(_legacy_model), _strip_aux_credential(_legacy.get("api_key")), _strip_aux_credential(_legacy.get("base_url")), + _merge_request_overrides({}, _legacy.get("extra_body")), ) # 3. Fall through to the main chat model - return _ReviewRuntimeBinding(_main_provider, _main_model, None, None) + return _ReviewRuntimeBinding(_main_provider, _main_model, None, None, {}) def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]: @@ -1850,6 +1866,11 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]: _base_url = None _api_mode = None _resolved_provider = None + _credential_pool = None + _request_overrides: Dict[str, Any] = {} + _max_tokens = None + _acp_command = None + _acp_args = None _model_name = "" try: from hermes_cli.config import load_config @@ -1867,6 +1888,16 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]: _base_url = _rp.get("base_url") _api_mode = _rp.get("api_mode") _resolved_provider = _rp.get("provider") or _provider + _credential_pool = _rp.get("credential_pool") + _request_overrides = _merge_request_overrides( + _rp.get("request_overrides"), + _binding.request_overrides.get("extra_body"), + ) + _max_tokens = _rp.get("max_output_tokens") + _acp_command = _rp.get("command") + _acp_args = list(_rp.get("args") or []) + if isinstance(_rp.get("model"), str) and _rp["model"].strip(): + _model_name = _rp["model"].strip() except Exception as e: logger.debug("Curator provider resolution failed: %s", e, exc_info=True) @@ -1875,12 +1906,21 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]: review_agent = None try: + _agent_kwargs: Dict[str, Any] = {} + if isinstance(_max_tokens, int): + _agent_kwargs["max_tokens"] = _max_tokens + if isinstance(_acp_command, str) and _acp_command: + _agent_kwargs["acp_command"] = _acp_command + _agent_kwargs["acp_args"] = _acp_args or [] review_agent = AIAgent( model=_model_name, provider=_resolved_provider, api_key=_api_key, base_url=_base_url, api_mode=_api_mode, + credential_pool=_credential_pool, + request_overrides=_request_overrides, + **_agent_kwargs, # Umbrella-building over a large skill collection is worth a # high iteration ceiling — the pass typically takes 50-100 # API calls against hundreds of candidate skills. The diff --git a/agent/display.py b/agent/display.py index 5a16a77d00d..66872c35d66 100644 --- a/agent/display.py +++ b/agent/display.py @@ -27,6 +27,14 @@ logger = logging.getLogger(__name__) _ANSI_RESET = "\033[0m" + +def _display_url(value: Any) -> str: + """Extract a display-only URL without assuming model argument types.""" + if isinstance(value, dict): + value = value.get("url") or value.get("href") + return value.strip() if isinstance(value, str) else "" + + # Diff colors — resolved lazily from the skin engine so they adapt # to light/dark themes. Falls back to sensible defaults on import # failure. We cache after first resolution for performance. @@ -1259,7 +1267,7 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str] return False, "" -def get_cute_tool_message( +def _get_cute_tool_message( tool_name: str, args: dict, duration: float, result: str | None = None, ) -> str: """Generate a formatted tool completion line for CLI quiet mode. @@ -1301,9 +1309,11 @@ def get_cute_tool_message( if tool_name == "web_extract": urls = args.get("urls", []) if urls: - url = urls[0] if isinstance(urls, list) else str(urls) + url = _display_url(urls[0] if isinstance(urls, list) else urls) + if not url: + return _wrap(f"┊ 📄 fetch pages {dur}") domain = url.replace("https://", "").replace("http://", "").split("/")[0] - extra = f" +{len(urls)-1}" if len(urls) > 1 else "" + extra = f" +{len(urls)-1}" if isinstance(urls, list) and len(urls) > 1 else "" return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}") return _wrap(f"┊ 📄 fetch pages {dur}") if tool_name == "terminal": @@ -1433,6 +1443,19 @@ def get_cute_tool_message( return _wrap(f"┊ ⚡ {tool_name[:9]:9} {_trunc(preview, 35)} {dur}") +def get_cute_tool_message( + tool_name: str, args: dict, duration: float, result: str | None = None, +) -> str: + """Render a completion label without letting cosmetic failures escape.""" + try: + return _get_cute_tool_message(tool_name, args, duration, result=result) + except Exception as exc: # noqa: BLE001 — display must never abort a turn + logger.debug("Tool completion label failed for %s: %s", tool_name, exc) + safe_name = tool_name[:9] if isinstance(tool_name, str) and tool_name else "tool" + safe_duration = f"{duration:.1f}s" if isinstance(duration, (int, float)) else "done" + return f"┊ ⚡ {safe_name:9} completed {safe_duration}" + + # ========================================================================= # Honcho session line (one-liner with clickable OSC 8 hyperlink) # ========================================================================= diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 4d75502dab4..5eb4c09e0f1 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -1147,6 +1147,7 @@ def _classify_400( "encrypted content for item" in error_msg and "could not be verified" in error_msg ) + or "could not decrypt the provided encrypted_content" in error_msg ): return result_fn( FailoverReason.invalid_encrypted_content, diff --git a/agent/insights.py b/agent/insights.py index 9977010549c..6adba701135 100644 --- a/agent/insights.py +++ b/agent/insights.py @@ -17,6 +17,7 @@ Usage: """ import json +import sqlite3 import time from collections import Counter, defaultdict from datetime import datetime @@ -141,8 +142,8 @@ class InsightsEngine: } # Compute insights - overview = self._compute_overview(sessions, message_stats) - models = self._compute_model_breakdown(sessions) + models = self._compute_model_breakdown(sessions, cutoff, source) + overview = self._compute_overview(sessions, message_stats, models) platforms = self._compute_platform_breakdown(sessions) tools = self._compute_tool_breakdown(tool_usage) skills = self._compute_skill_breakdown(skill_usage) @@ -172,7 +173,7 @@ class InsightsEngine: "message_count, tool_call_count, input_tokens, output_tokens, " "cache_read_tokens, cache_write_tokens, billing_provider, " "billing_base_url, billing_mode, estimated_cost_usd, " - "actual_cost_usd, cost_status, cost_source") + "actual_cost_usd, cost_status, cost_source, api_call_count") # Pre-computed query strings — f-string evaluated once at class definition, # not at runtime, so no user-controlled value can alter the query structure. @@ -399,7 +400,12 @@ class InsightsEngine: # Computation # ========================================================================= - def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict: + def _compute_overview( + self, + sessions: List[Dict], + message_stats: Dict, + models: Optional[List[Dict]] = None, + ) -> Dict: """Compute high-level overview statistics.""" total_input = sum(s.get("input_tokens") or 0 for s in sessions) total_output = sum(s.get("output_tokens") or 0 for s in sessions) @@ -431,6 +437,9 @@ class InsightsEngine: else: models_without_pricing.add(display) + if models: + total_cost = sum(float(m.get("cost") or 0.0) for m in models) + # Session duration stats (guard against negative durations from clock drift) durations = [] for s in sessions: @@ -473,39 +482,189 @@ class InsightsEngine: "included_cost_sessions": included_cost_sessions, } - def _compute_model_breakdown(self, sessions: List[Dict]) -> List[Dict]: - """Break down usage by model.""" + _GET_MODEL_USAGE_WITH_SOURCE = ( + "SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url," + " u.api_call_count, u.input_tokens, u.output_tokens," + " u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens," + " u.estimated_cost_usd, u.actual_cost_usd, u.cost_status," + " u.cost_source, u.billing_mode" + " FROM session_model_usage u" + " JOIN sessions s ON s.id = u.session_id" + " WHERE s.started_at >= ? AND s.source = ?" + ) + _GET_MODEL_USAGE_ALL = ( + "SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url," + " u.api_call_count, u.input_tokens, u.output_tokens," + " u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens," + " u.estimated_cost_usd, u.actual_cost_usd, u.cost_status," + " u.cost_source, u.billing_mode" + " FROM session_model_usage u" + " JOIN sessions s ON s.id = u.session_id" + " WHERE s.started_at >= ?" + ) + + def _get_model_usage(self, cutoff: float, source: str = None) -> List[Dict]: + """Fetch per-model usage rows within the window (issue #51607). + + Returns an empty list when the table is missing (e.g. a DB opened by + older code that never created it) so the caller can fall back to the + per-session aggregate. + """ + try: + if source: + cursor = self._conn.execute( + self._GET_MODEL_USAGE_WITH_SOURCE, (cutoff, source) + ) + else: + cursor = self._conn.execute(self._GET_MODEL_USAGE_ALL, (cutoff,)) + return [dict(row) for row in cursor.fetchall()] + except sqlite3.OperationalError: + return [] + + def _compute_model_breakdown( + self, sessions: List[Dict], cutoff: float, source: str = None + ) -> List[Dict]: + """Break down token usage and cost by model. + + Tokens and cost are attributed per model from session_model_usage, so a + session that switched models mid-flight (via ``/model``) splits across + every model it used instead of dumping everything on the initial model + (issue #51607). Sessions without per-model rows — e.g. data written + before this table existed and not yet backfilled — fall back to their + single recorded (model, billing_provider) aggregate so nothing is lost. + + Tool calls aren't tied to a specific API invocation, so they stay + attributed to the session's recorded model. + """ model_data = defaultdict(lambda: { - "sessions": 0, "input_tokens": 0, "output_tokens": 0, + "sessions": set(), "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, - "total_tokens": 0, "tool_calls": 0, "cost": 0.0, + "reasoning_tokens": 0, "total_tokens": 0, "api_calls": 0, + "tool_calls": 0, "cost": 0.0, "actual_cost": 0.0, }) - for s in sessions: - model = s.get("model") or "unknown" + def _accumulate(model, provider, base_url, session_id, inp, out, + cache_read, cache_write, reasoning, *, + stored_cost=None, actual_cost=None, cost_status=None): + model = model or "unknown" # Normalize: strip provider prefix for display display_model = model.split("/")[-1] if "/" in model else model - d = model_data[display_model] - d["sessions"] += 1 - inp = s.get("input_tokens") or 0 - out = s.get("output_tokens") or 0 - cache_read = s.get("cache_read_tokens") or 0 - cache_write = s.get("cache_write_tokens") or 0 + d: Dict[str, Any] = model_data[display_model] + d["sessions"].add(session_id) d["input_tokens"] += inp d["output_tokens"] += out d["cache_read_tokens"] += cache_read d["cache_write_tokens"] += cache_write + d["reasoning_tokens"] += reasoning d["total_tokens"] += inp + out + cache_read + cache_write - d["tool_calls"] += s.get("tool_call_count") or 0 - estimate, status = _estimate_cost(s) + if stored_cost is None: + estimate, status = _estimate_cost( + model, inp, out, + cache_read_tokens=cache_read, cache_write_tokens=cache_write, + provider=provider or None, base_url=base_url, + ) + else: + estimate = float(stored_cost or 0.0) + status = cost_status or "unknown" d["cost"] += estimate - d["has_pricing"] = has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")) + d["actual_cost"] += float(actual_cost or 0.0) d["cost_status"] = status + if has_known_pricing(model, provider or None, base_url): + d["has_pricing"] = True + else: + d.setdefault("has_pricing", False) + return display_model - result = [ - {"model": model, **data} - for model, data in model_data.items() - ] + usage_rows = self._get_model_usage(cutoff, source) + usage_totals = defaultdict(lambda: { + "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, + "cache_write_tokens": 0, "reasoning_tokens": 0, + "api_call_count": 0, "estimated_cost_usd": 0.0, + "actual_cost_usd": 0.0, + }) + for r in usage_rows: + totals: Dict[str, Any] = usage_totals[r["session_id"]] + for key in ( + "input_tokens", "output_tokens", "cache_read_tokens", + "cache_write_tokens", "reasoning_tokens", "api_call_count", + ): + totals[key] += r[key] or 0 + totals["estimated_cost_usd"] += r["estimated_cost_usd"] or 0.0 + totals["actual_cost_usd"] += r["actual_cost_usd"] or 0.0 + d = _accumulate( + r["model"], r["billing_provider"], r.get("billing_base_url"), + r["session_id"], r["input_tokens"] or 0, r["output_tokens"] or 0, + r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0, + r["reasoning_tokens"] or 0, + stored_cost=( + r["estimated_cost_usd"] + if r.get("cost_status") or r.get("cost_source") + else None + ), + actual_cost=r["actual_cost_usd"], + cost_status=r.get("cost_status"), + ) + model_data[d]["api_calls"] += r["api_call_count"] or 0 + + # Reconcile against the aggregate row. This covers legacy sessions, + # interrupted migrations, and absolute cumulative updates without + # double-counting already-attributed route deltas. + for s in sessions: + totals = usage_totals[s["id"]] + inp = max(0, (s.get("input_tokens") or 0) - totals["input_tokens"]) + out = max(0, (s.get("output_tokens") or 0) - totals["output_tokens"]) + cache_read = max( + 0, (s.get("cache_read_tokens") or 0) - totals["cache_read_tokens"] + ) + cache_write = max( + 0, (s.get("cache_write_tokens") or 0) - totals["cache_write_tokens"] + ) + residual_cost = max( + 0.0, float(s.get("estimated_cost_usd") or 0.0) + - totals["estimated_cost_usd"], + ) + residual_actual = max( + 0.0, float(s.get("actual_cost_usd") or 0.0) + - totals["actual_cost_usd"], + ) + residual_calls = max( + 0, (s.get("api_call_count") or 0) - totals["api_call_count"] + ) + if not ( + inp or out or cache_read or cache_write or residual_cost + or residual_actual or residual_calls + ): + continue + d = _accumulate( + s.get("model"), s.get("billing_provider"), + s.get("billing_base_url"), s["id"], + inp, out, cache_read, cache_write, 0, + stored_cost=residual_cost, + actual_cost=residual_actual, + cost_status=s.get("cost_status"), + ) + residual_bucket: Dict[str, Any] = model_data[d] + residual_bucket["api_calls"] += residual_calls + + # Tool calls are attributed by the session's recorded model. + for s in sessions: + tool_calls = s.get("tool_call_count") or 0 + if not tool_calls: + continue + model = s.get("model") or "unknown" + display_model = model.split("/")[-1] if "/" in model else model + model_data[display_model]["tool_calls"] += tool_calls + + result = [] + for model, data in model_data.items(): + entry = {"model": model, **data} + entry["sessions"] = len(data["sessions"]) + # Models that surfaced only via tool-call attribution (no token + # rows) won't have these set by _accumulate — default them so the + # output shape is uniform for downstream/JSON consumers. + entry.setdefault("has_pricing", False) + entry.setdefault("cost_status", "unknown") + result.append(entry) # Sort by tokens first, fall back to session count when tokens are 0 result.sort(key=lambda x: (x["total_tokens"], x["sessions"]), reverse=True) return result diff --git a/agent/memory_manager.py b/agent/memory_manager.py index c8b80a1514e..32dd8e28884 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -783,6 +783,55 @@ class MemoryManager: exc_info=True, ) + def commit_session_boundary_async( + self, + messages: List[Dict[str, Any]], + *, + new_session_id: str, + parent_session_id: str = "", + reason: str = "new_session", + ) -> None: + """Queue old-session extraction + provider rebinding as ONE serialized task. + + Session rotation (/new) must deliver ``on_session_end`` (end-of-session + extraction — an LLM-bound call that can take seconds) strictly BEFORE + ``on_session_switch`` (which rebinds provider-internal ``_session_id`` / + turn buffers to the new session). Running extraction inline blocked the + /new command for the whole LLM round-trip (#16454); running it on an + ad-hoc thread raced the inline switch — providers key off internal + state, so a late ``on_session_end`` ran against post-switch bindings + (transcript misattributed to the new session id, double-ingest of the + old turn buffer, new-session buffers cleared). + + Submitting BOTH hooks as one task on the manager's single background + worker gives both properties at a single chokepoint: the caller returns + immediately, and the worker's FIFO order serializes end→switch against + every other provider write (per-turn ``sync_all``, prefetches), which + already share the same worker. If the executor is unavailable, + ``_submit_background`` degrades to inline execution — the pre-#16454 + synchronous behavior, slow but correct. + """ + if not self._providers: + return + snapshot = list(messages or []) + + def _run() -> None: + try: + self.on_session_end(snapshot) + except Exception as e: # pragma: no cover - on_session_end guards per-provider + logger.warning("Session-boundary extraction failed: %s", e) + try: + self.on_session_switch( + new_session_id, + parent_session_id=parent_session_id, + reset=True, + reason=reason, + ) + except Exception as e: # pragma: no cover - on_session_switch guards per-provider + logger.warning("Session-boundary switch failed: %s", e) + + self._submit_background(_run) + def on_session_switch( self, new_session_id: str, diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 9700f4abe85..cd7fa3ea4ea 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -120,7 +120,7 @@ _REFERENCE_SYSTEM_PROMPT = ( def _slot_label(slot: dict[str, str]) -> str: - return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}" + return f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}" def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]: diff --git a/agent/model_metadata.py b/agent/model_metadata.py index cca79be75a6..79862adbf6c 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -47,7 +47,7 @@ def _resolve_requests_verify() -> bool | str: # are preserved so the full model name reaches cache lookups and server queries. _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", - "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", + "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", "deepinfra", "opencode-zen", "opencode-go", "kilocode", "alibaba", "novita", "qwen-oauth", "xiaomi", @@ -58,7 +58,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({ # Common aliases "google", "google-gemini", "google-ai-studio", "glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot", - "github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", + "github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", "deep-infra", "ollama", "stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen", "mimo", "xiaomi-mimo", @@ -111,6 +111,15 @@ _MODEL_CACHE_TTL = 3600 _endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {} _endpoint_model_metadata_cache_time: Dict[str, float] = {} _ENDPOINT_MODEL_CACHE_TTL = 300 +# Bounded-lifetime cache: after the first successful probe we remember the +# server type so subsequent refreshes skip the full waterfall (no more 404 +# spam every 5 minutes on non-matching endpoints like /api/v1/models on vllm). +# Entries expire after _ENDPOINT_PROBE_TTL_SECONDS so a server swap on the +# same port (stop Ollama, start LM Studio) is eventually re-detected instead +# of being pinned to the stale type for the whole process lifetime. +# Values are (server_type, monotonic_timestamp). +_ENDPOINT_PROBE_TTL_SECONDS = 3600.0 +_endpoint_probe_path_cache: Dict[str, tuple] = {} def _get_model_metadata_cache_path() -> Path: @@ -220,6 +229,12 @@ DEFAULT_CONTEXT_LENGTHS = { # ChatGPT Codex OAuth caps it at 272K; both paths resolve via their own # provider-aware branches (_resolve_codex_oauth_context_length + models.dev). # This hardcoded value is only reached when every probe misses. + # GPT-5.6 series (Sol/Terra/Luna, GA 2026-07-09) — 1.05M on the direct + # OpenAI API (same as gpt-5.5). Codex OAuth caps these at 272K. + # (Lookups length-sort keys at match time, so dict order is cosmetic.) + "gpt-5.6-luna": 1050000, + "gpt-5.6-terra": 1050000, + "gpt-5.6-sol": 1050000, "gpt-5.5": 1050000, "gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4) "gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4) @@ -632,66 +647,109 @@ def is_local_endpoint(base_url: str) -> bool: return False +def _localhost_to_ipv4(url: str) -> str: + """Rewrite a ``localhost`` HOST to ``127.0.0.1`` in a probe URL. + + On Windows dual-stack machines, httpx resolves ``localhost`` to ``::1`` + first and pays a ~2s IPv6 connect timeout before falling back to IPv4 + when the local server only listens on IPv4 (LM Studio, Ollama defaults). + Probing the IPv4 loopback directly skips that penalty. + + Only the URL's own host component is rewritten (anchored at the scheme), + so a non-localhost URL whose path or query merely embeds the substring + ``http://localhost...`` (e.g. ``?upstream=http://localhost:11434``) + passes through untouched. + """ + if not url: + return url + return re.sub( + r"^(https?://)localhost(?=[:/]|$)", + r"\g<1>127.0.0.1", + url, + count=1, + ) + + def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: """Detect which local server is running at base_url by probing known endpoints. Returns one of: "ollama", "lm-studio", "vllm", "llamacpp", or None. + + The result is cached for the lifetime of the process so that repeated + calls (e.g. every 5-minute metadata refresh) never re-run the waterfall + and never spray 404s at endpoints the server does not expose. """ import httpx normalized = _normalize_base_url(base_url) + + # Resolve localhost to IPv4 to avoid 2s IPv6 timeout on Windows dual-stack. + # Applied to ``normalized`` before deriving server/LM Studio URLs AND + # before the cache lookup, so localhost and 127.0.0.1 share a cache entry. + normalized = _localhost_to_ipv4(normalized) + server_url = normalized if server_url.endswith("/v1"): server_url = server_url[:-3] - lmstudio_url = _lmstudio_server_root(base_url) + lmstudio_url = _lmstudio_server_root(normalized) + + cached = _endpoint_probe_path_cache.get(server_url) + if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS: + return cached[0] headers = _auth_headers(api_key) + result: Optional[str] = None try: with httpx.Client(timeout=2.0, headers=headers) as client: # LM Studio exposes /api/v1/models — check first (most specific) try: r = client.get(f"{lmstudio_url}/api/v1/models") if r.status_code == 200: - return "lm-studio" + result = "lm-studio" except Exception: pass - # Ollama exposes /api/tags and responds with {"models": [...]} - # LM Studio returns {"error": "Unexpected endpoint"} with status 200 - # on this path, so we must verify the response contains "models". - try: - r = client.get(f"{server_url}/api/tags") - if r.status_code == 200: - try: + if result is None: + # Ollama exposes /api/tags and responds with {"models": [...]} + # LM Studio returns {"error": "Unexpected endpoint"} with status 200 + # on this path, so we must verify the response contains "models". + try: + r = client.get(f"{server_url}/api/tags") + if r.status_code == 200: + try: + data = r.json() + if "models" in data: + result = "ollama" + except Exception: + pass + except Exception: + pass + if result is None: + # llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix) + try: + r = client.get(f"{server_url}/v1/props") + if r.status_code != 200: + r = client.get(f"{server_url}/props") # fallback for older builds + if r.status_code == 200 and "default_generation_settings" in r.text: + result = "llamacpp" + except Exception: + pass + if result is None: + # vLLM: /version + try: + r = client.get(f"{server_url}/version") + if r.status_code == 200: data = r.json() - if "models" in data: - return "ollama" - except Exception: - pass - except Exception: - pass - # llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix) - try: - r = client.get(f"{server_url}/v1/props") - if r.status_code != 200: - r = client.get(f"{server_url}/props") # fallback for older builds - if r.status_code == 200 and "default_generation_settings" in r.text: - return "llamacpp" - except Exception: - pass - # vLLM: /version - try: - r = client.get(f"{server_url}/version") - if r.status_code == 200: - data = r.json() - if "version" in data: - return "vllm" - except Exception: - pass + if "version" in data: + result = "vllm" + except Exception: + pass except Exception: pass - return None + if result is not None: + _endpoint_probe_path_cache[server_url] = (result, time.monotonic()) + return result def _iter_nested_dicts(value: Any): @@ -749,6 +807,24 @@ def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]: pricing["completion"] = str(float(novita_output) / 10_000 / 1_000_000) return pricing + # DeepInfra ships pricing under ``metadata.pricing`` with $/MTok values: + # ``input_tokens``, ``output_tokens``, ``cache_read_tokens``. Convert to + # per-token strings so the generic cost machinery (usage_pricing.py) + # consumes them through the same path as OpenRouter / OpenAI. + metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else None + deepinfra_pricing = metadata.get("pricing") if metadata else None + if isinstance(deepinfra_pricing, dict) and any( + k in deepinfra_pricing for k in ("input_tokens", "output_tokens", "cache_read_tokens") + ): + result: Dict[str, Any] = {} + if deepinfra_pricing.get("input_tokens") is not None: + result["prompt"] = str(float(deepinfra_pricing["input_tokens"]) / 1_000_000) + if deepinfra_pricing.get("output_tokens") is not None: + result["completion"] = str(float(deepinfra_pricing["output_tokens"]) / 1_000_000) + if deepinfra_pricing.get("cache_read_tokens") is not None: + result["cache_read"] = str(float(deepinfra_pricing["cache_read_tokens"]) / 1_000_000) + return result + alias_map = { "prompt": ("prompt", "input", "input_cost_per_token", "prompt_token_cost"), "completion": ("completion", "output", "output_cost_per_token", "completion_token_cost"), @@ -795,7 +871,10 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any return _model_metadata_cache try: - response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify()) + # Tuple (connect, read) — flat timeout=10 means urllib3 can block 10s per + # retry stage through proxies that 403 CONNECT, ballooning to minutes + # (#46620). 5s connect / 10s read fails fast on unreachable hosts. + response = requests.get(OPENROUTER_MODELS_URL, timeout=(5, 10), verify=_resolve_requests_verify()) response.raise_for_status() data = response.json() @@ -873,7 +952,7 @@ def fetch_endpoint_model_metadata( response = requests.get( server_url.rstrip("/") + "/api/v1/models", headers=headers, - timeout=10, + timeout=(5, 10), verify=_resolve_requests_verify(), ) response.raise_for_status() @@ -921,7 +1000,7 @@ def fetch_endpoint_model_metadata( for candidate in candidates: url = candidate.rstrip("/") + "/models" try: - response = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify()) + response = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify()) response.raise_for_status() payload = response.json() cache: Dict[str, Dict[str, Any]] = {} @@ -1016,19 +1095,29 @@ def _load_context_cache() -> Dict[str, int]: try: with open(path, encoding="utf-8") as f: data = yaml.safe_load(f) or {} - return data.get("context_lengths", {}) + return data.get("context_lengths") or {} except Exception as e: logger.debug("Failed to load context length cache: %s", e) return {} +def _context_cache_key(model: str, base_url: str) -> str: + """Canonical ``model@base_url`` key for the persistent context cache. + + Trailing slashes are stripped so ``http://host/v1`` and + ``http://host/v1/`` share one entry instead of creating duplicates + that can go stale independently. + """ + return f"{model}@{(base_url or '').rstrip('/')}" + + def save_context_length(model: str, base_url: str, length: int) -> None: """Persist a discovered context length for a model+provider combo. Cache key is ``model@base_url`` so the same model name served from different providers can have different limits. """ - key = f"{model}@{base_url}" + key = _context_cache_key(model, base_url) cache = _load_context_cache() if cache.get(key) == length: return # already stored @@ -1045,18 +1134,43 @@ def save_context_length(model: str, base_url: str, length: int) -> None: def get_cached_context_length(model: str, base_url: str) -> Optional[int]: """Look up a previously discovered context length for model+provider.""" - key = f"{model}@{base_url}" + key = _context_cache_key(model, base_url) cache = _load_context_cache() - return cache.get(key) + hit = cache.get(key) + if hit is not None: + return hit + # Legacy rows written before key normalization may carry a trailing + # slash — honor them rather than re-probing. Checked regardless of the + # caller's slash form: the row's shape and the caller's shape can differ + # in either direction (old slashed row + new normalized config, or the + # reverse), so probe the literal form and the slashed canonical form. + for legacy_key in (f"{model}@{base_url}", f"{key}/"): + if legacy_key != key: + hit = cache.get(legacy_key) + if hit is not None: + return hit + return None def _invalidate_cached_context_length(model: str, base_url: str) -> None: """Drop a stale cache entry so it gets re-resolved on the next lookup.""" - key = f"{model}@{base_url}" + key = _context_cache_key(model, base_url) cache = _load_context_cache() - if key not in cache: + # Invalidation must also drop the in-memory TTL probe entries for this + # pair — otherwise the next resolution inside the TTL window reuses the + # very value we just declared stale and re-persists it. + bare = _strip_provider_prefix(model) + stripped = (base_url or "").rstrip("/") + _LOCAL_CTX_PROBE_CACHE.pop((bare, stripped), None) + _LOCAL_CTX_PROBE_CACHE.pop(("ollama_show", bare, stripped), None) + # Clear every key shape for this pair: canonical, the caller's literal + # form, and the slashed legacy form — same set get_cached_context_length + # consults, so a lookup can never resurrect a row invalidation missed. + stale_keys = {key, f"{model}@{base_url}", f"{key}/"} + if not any(k in cache for k in stale_keys): return - del cache[key] + for k in stale_keys: + cache.pop(k, None) path = _get_context_cache_path() try: path.parent.mkdir(parents=True, exist_ok=True) @@ -1343,7 +1457,7 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option import httpx bare_model = _strip_provider_prefix(model) - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1404,7 +1518,7 @@ def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") - except Exception: return None - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1488,6 +1602,12 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti hosting behind a reverse proxy, etc. For non-Ollama servers the POST returns 404/405 quickly; the function handles errors gracefully. + Results are cached in ``_LOCAL_CTX_PROBE_CACHE`` (same 30s TTL, + positive-only — see ``_query_local_context_length``) so back-to-back + resolutions during one startup issue a single POST instead of one per + call site. Failures are never memoized: a server that isn't up yet must + be re-probed once it comes up. + For hosted servers the GGUF ``model_info.*.context_length`` is the authoritative source: the user can't set their own ``num_ctx``, and the OpenAI-compat ``/v1/models`` endpoint correctly omits ``context_length`` @@ -1499,9 +1619,28 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti The order is flipped vs ``query_ollama_num_ctx()`` because local users control ``num_ctx`` themselves; hosted users can't. """ + import time as _time + + # Namespaced cache key: shares the TTL store with + # _query_local_context_length but never collides with its (model, url) + # keys — the two probes can return different values for the same pair. + cache_key = ("ollama_show", _strip_provider_prefix(model), base_url.rstrip("/")) + now = _time.monotonic() + cached = _LOCAL_CTX_PROBE_CACHE.get(cache_key) + if cached is not None and (now - cached[1]) < _LOCAL_CTX_PROBE_TTL_SECONDS: + return cached[0] + + result = _query_ollama_api_show_uncached(model, base_url, api_key=api_key) + if result: # positive-only — never memoize a failed probe + _LOCAL_CTX_PROBE_CACHE[cache_key] = (result, now) + return result + + +def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "") -> Optional[int]: + """Uncached body of ``_query_ollama_api_show`` — one POST to ``/api/show``.""" import httpx - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1620,10 +1759,10 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str model = _strip_provider_prefix(model) # Strip /v1 suffix to get the server root - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] - lmstudio_url = _lmstudio_server_root(base_url) + lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url)) headers = _auth_headers(api_key) @@ -1733,7 +1872,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> "x-api-key": api_key, "anthropic-version": "2023-06-01", } - resp = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify()) + resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify()) if resp.status_code != 200: return None data = resp.json() @@ -1767,6 +1906,9 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = { "gpt-5.3-codex-spark": 128_000, "gpt-5.2-codex": 272_000, "gpt-5.4-mini": 272_000, + "gpt-5.6-sol": 272_000, + "gpt-5.6-terra": 272_000, + "gpt-5.6-luna": 272_000, "gpt-5.5": 272_000, "gpt-5.4": 272_000, "gpt-5.2": 272_000, @@ -1800,7 +1942,7 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]: resp = requests.get( "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0", headers={"Authorization": f"Bearer {access_token}"}, - timeout=10, + timeout=(5, 10), verify=_resolve_requests_verify(), ) if resp.status_code != 200: @@ -2484,5 +2626,82 @@ def estimate_request_tokens_rough( if messages: total += estimate_messages_tokens_rough(messages) if tools: - total += (len(str(tools)) + 3) // 4 + total += _estimate_tools_tokens_rough(tools) return total + + +# NOTE: tool schemas can be large. Avoid repeated `str(tools)` conversions, +# which are CPU-heavy and can stall GUI event loops under GIL pressure. +# +# Keyed by ``id(tools)``. A long-lived gateway/desktop backend builds many +# transient tool lists over its lifetime, so the cache is bounded and evicts +# oldest-first (insertion-ordered dict) once it exceeds the cap. The cap is +# generous relative to how rarely toolsets are rebuilt within a process. +_TOOLS_TOKENS_CACHE: dict[int, Tuple[int, str, str, int]] = {} +_TOOLS_TOKENS_CACHE_MAX = 256 + + +def _tool_name_for_cache(tool: Any) -> str: + if not isinstance(tool, dict): + return "" + fn = tool.get("function") + if isinstance(fn, dict): + name = fn.get("name") + if isinstance(name, str): + return name + name = tool.get("name") + return name if isinstance(name, str) else "" + + +def _estimate_tools_tokens_rough(tools: List[Dict[str, Any]]) -> int: + if not tools: + return 0 + + # Cache by list identity. Tools are rebuilt rarely (toolset changes), + # but token estimates are requested frequently (preflight, compaction). + key = id(tools) + n = len(tools) + first = _tool_name_for_cache(tools[0]) if n else "" + last = _tool_name_for_cache(tools[-1]) if n else "" + + cached = _TOOLS_TOKENS_CACHE.get(key) + if cached is not None: + cached_n, cached_first, cached_last, cached_tokens = cached + if cached_n == n and cached_first == first and cached_last == last: + return cached_tokens + + # Fast, stable rough estimate: sum lengths of the major schema fields. + # This avoids the pathological `str(tools)` path while still scaling with + # schema size (descriptions + parameters dominate). + total_chars = 0 + for tool in tools: + if not isinstance(tool, dict): + continue + fn = tool.get("function") + if isinstance(fn, dict): + name = fn.get("name") or "" + desc = fn.get("description") or "" + params = fn.get("parameters") or {} + else: + name = tool.get("name") or "" + desc = tool.get("description") or "" + params = tool.get("parameters") or {} + + if isinstance(name, str): + total_chars += len(name) + if isinstance(desc, str): + total_chars += len(desc) + # Parameters can be nested; JSON is closer to over-the-wire size than repr(). + try: + total_chars += len(json.dumps(params, ensure_ascii=False, separators=(",", ":"))) + except Exception: + total_chars += len(str(params)) + + tokens = (total_chars + 3) // 4 + # Bound the cache: drop the oldest entry when the cap is exceeded so a + # long-running process can't accumulate an unbounded number of stale + # ``id(tools)`` entries (id values are recycled after GC anyway). + if len(_TOOLS_TOKENS_CACHE) >= _TOOLS_TOKENS_CACHE_MAX: + _TOOLS_TOKENS_CACHE.pop(next(iter(_TOOLS_TOKENS_CACHE)), None) + _TOOLS_TOKENS_CACHE[key] = (n, first, last, tokens) + return tokens diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index abb70b543cb..b5b2b58c362 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -7,6 +7,7 @@ assemble pieces, then combines them with memory and ephemeral prompts. import json import logging import os +import sys import threading import contextvars from collections import OrderedDict @@ -17,6 +18,8 @@ from typing import Optional from agent.runtime_cwd import resolve_agent_cwd from agent.skill_utils import ( + EXCLUDED_SKILL_DIRS, + SKILL_SUPPORT_DIRS, extract_skill_conditions, extract_skill_description, get_all_skills_dirs, @@ -25,6 +28,7 @@ from agent.skill_utils import ( parse_frontmatter, skill_matches_environment, skill_matches_platform, + skill_matches_platform_list, ) from utils import atomic_json_write @@ -1271,13 +1275,26 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None: def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]: """Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files.""" manifest: dict[str, list[int]] = {} - for filename in ("SKILL.md", "DESCRIPTION.md"): - for path in iter_skill_index_files(skills_dir, filename): + skills_dir_str = str(skills_dir) + base = os.path.join(skills_dir_str, "") + prefix_len = len(base) + for root, dirs, files in os.walk(skills_dir_str, followlinks=True): + has_skill_md = "SKILL.md" in files + dirs[:] = [ + d + for d in dirs + if d not in EXCLUDED_SKILL_DIRS + and not (has_skill_md and d in SKILL_SUPPORT_DIRS) + ] + for filename in ("SKILL.md", "DESCRIPTION.md"): + if filename not in files: + continue + path = os.path.join(root, filename) try: - st = path.stat() + st = os.stat(path) except OSError: continue - manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size] + manifest[path[prefix_len:]] = [st.st_mtime_ns, st.st_size] return manifest @@ -1409,6 +1426,22 @@ def _skill_should_show( return True +def _current_session_platform_hint() -> str: + """Return the active platform without importing the gateway package on CLI startup.""" + platform = os.environ.get("HERMES_PLATFORM") or os.environ.get("HERMES_SESSION_PLATFORM") + if platform: + return platform + + session_context = sys.modules.get("gateway.session_context") + get_session_env = getattr(session_context, "get_session_env", None) if session_context else None + if get_session_env is None: + return "" + try: + return get_session_env("HERMES_SESSION_PLATFORM") or "" + except Exception: + return "" + + def build_skills_system_prompt( available_tools: "set[str] | None" = None, available_toolsets: "set[str] | None" = None, @@ -1443,15 +1476,10 @@ def build_skills_system_prompt( # ── Layer 1: in-process LRU cache ───────────────────────────────── # Include the resolved platform so per-platform disabled-skill lists # produce distinct cache entries (gateway serves multiple platforms). - from gateway.session_context import get_session_env - _platform_hint = ( - os.environ.get("HERMES_PLATFORM") - or get_session_env("HERMES_SESSION_PLATFORM") - or "" - ) + _platform_hint = _current_session_platform_hint() disabled = get_disabled_skill_names(_platform_hint or None) cache_key = ( - str(skills_dir.resolve()), + str(skills_dir), tuple(str(d) for d in external_dirs), tuple(sorted(str(t) for t in (available_tools or set()))), tuple(sorted(str(ts) for ts in (available_toolsets or set()))), @@ -1480,7 +1508,7 @@ def build_skills_system_prompt( category = entry.get("category") or "general" frontmatter_name = entry.get("frontmatter_name") or skill_name platforms = entry.get("platforms") or [] - if not skill_matches_platform({"platforms": platforms}): + if not skill_matches_platform_list(platforms): continue if frontmatter_name in disabled or skill_name in disabled: continue diff --git a/agent/reactions.py b/agent/reactions.py new file mode 100644 index 00000000000..375366ff709 --- /dev/null +++ b/agent/reactions.py @@ -0,0 +1,56 @@ +"""Token-free detection of user *reactions* to the agent. + +Currently the only reaction is ``vibe`` — an expression of affection or +gratitude toward the agent (``ily``, ``<3``, ``love you``, ``good bot``, a heart +emoji, …). Detection is a curated regex/lexicon: **no model call, no tokens**. + +This is the single source of truth shared by every surface — the CLI pet, the +TUI heart, and the desktop floating hearts all react off the same signal, +delivered via ``AIAgent.reaction_callback`` (wired per interactive host). + +Generalized on purpose: :func:`detect_reaction` returns a reaction *kind* +string, so new kinds (other emoji reactions, etc.) can be added here without +touching any caller. We match affection specifically — not general positive +sentiment — so "this is great" does NOT fire, but "good bot" / "❤️" do. +""" + +from __future__ import annotations + +import re + +#: The affection/gratitude reaction — the only kind today. +VIBE = "vibe" + +# Curated affection lexicon. Kept deliberately narrow: gratitude + love aimed at +# the agent, heart emoji, and ``<3`` (but not the broken heart `` str | None: + """Return the reaction kind for *text* (currently :data:`VIBE`), or ``None``. + + Pure, token-free, and safe to call on every user turn. + """ + if not text: + return None + + return VIBE if _VIBE_RE.search(text) else None diff --git a/agent/reasoning_timeouts.py b/agent/reasoning_timeouts.py index 9e0b5cab9b9..768ce0494de 100644 --- a/agent/reasoning_timeouts.py +++ b/agent/reasoning_timeouts.py @@ -66,9 +66,13 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = ( ("nemotron-3-ultra", 600), ("nemotron-3-super", 600), ("nemotron-3-nano", 300), - # DeepSeek — R1 reasoning model on hosted NIM / DeepSeek direct. + # DeepSeek — R1 and V4 reasoning models on hosted NIM / DeepSeek direct. + # V4 series emits reasoning_content in a separate delta field before + # final content, requiring the same extended stale timeout floor. ("deepseek-r1", 600), ("deepseek-reasoner", 600), + ("deepseek-v4-flash", 600), + ("deepseek-v4-pro", 600), # Qwen — QwQ reasoning + Qwen3 thinking variants. QwQ-32B # preview is the stable slug; ``qwen3`` covers the family of # thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.) @@ -190,6 +194,10 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]: 300.0 >>> get_reasoning_stale_timeout_floor("deepseek/deepseek-r1") 600.0 + >>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-flash") + 600.0 + >>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-pro") + 600.0 >>> get_reasoning_stale_timeout_floor("qwen/qwen3-235b-a22b-thinking") 180.0 >>> get_reasoning_stale_timeout_floor("x-ai/grok-4-fast-reasoning") diff --git a/agent/replay_cleanup.py b/agent/replay_cleanup.py index 84815d756fa..19ed38e059b 100644 --- a/agent/replay_cleanup.py +++ b/agent/replay_cleanup.py @@ -20,6 +20,9 @@ from __future__ import annotations import logging from typing import Any, Dict, List +from agent.tool_dispatch_helpers import make_tool_result_message +from agent.tool_result_classification import tool_may_have_side_effect + logger = logging.getLogger(__name__) @@ -64,8 +67,40 @@ def strip_interrupted_tool_tails( is_interrupted_tool_result(m.get("content", "")) for m in tool_results ): + calls = msg.get("tool_calls") or [] + if any( + tool_may_have_side_effect( + str((call.get("function") or {}).get("name") or "") + ) + for call in calls + ): + call_names = { + str(call.get("id") or call.get("call_id") or ""): str( + (call.get("function") or {}).get("name") or "" + ) + for call in calls + } + cleaned.append(msg) + for tool_result in tool_results: + if not is_interrupted_tool_result(tool_result.get("content", "")): + cleaned.append(tool_result) + continue + recovered = dict(tool_result) + name = call_names.get(str(tool_result.get("tool_call_id") or ""), "") + recovered["effect_disposition"] = ( + "unknown" if tool_may_have_side_effect(name) else "none" + ) + recovered["content"] = ( + "[Orphan recovery: interrupted side-effecting tool may have " + "executed; its effect is UNKNOWN. Inspect state before retrying.]" + if recovered["effect_disposition"] == "unknown" + else "[Orphan recovery: interrupted read-only tool did not complete.]" + ) + cleaned.append(recovered) + i = j + continue logger.debug( - "Stripping interrupted assistant→tool replay block " + "Stripping interrupted read-only assistant→tool replay block " "(indices %d–%d, tool_results=%d)", i, j - 1, len(tool_results), ) @@ -116,11 +151,36 @@ def strip_dangling_tool_call_tail( ): return agent_history + tool_calls = last.get("tool_calls") or [] + if any( + tool_may_have_side_effect( + str((call.get("function") or {}).get("name") or "") + ) + for call in tool_calls + ): + recovered = list(agent_history) + for call in tool_calls: + function = call.get("function") or {} + name = str(function.get("name") or "unknown") + call_id = str(call.get("id") or call.get("call_id") or "") + disposition = "unknown" if tool_may_have_side_effect(name) else "none" + content = ( + "[Orphan recovery: this tool may have executed before Hermes stopped; " + "its effect is UNKNOWN. Inspect current state before retrying.]" + if disposition == "unknown" + else "[Orphan recovery: this read-only tool did not complete and had no effect.]" + ) + recovered.append(make_tool_result_message( + name, content, call_id, effect_disposition=disposition, + )) + logger.warning( + "Recovered dangling side-effecting tool call(s) as UNKNOWN instead of erasing them" + ) + return recovered + logger.debug( - "Stripping dangling unanswered assistant(tool_calls) tail " - "(%d call(s)) — process likely killed mid-tool-call by a " - "restart/shutdown command (#49201)", - len(last.get("tool_calls") or []), + "Stripping dangling unanswered read-only assistant(tool_calls) tail (%d call(s))", + len(tool_calls), ) return agent_history[:-1] diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 23c8d99c997..d07bb5324e0 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -160,27 +160,8 @@ def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]: # ── Platform matching ───────────────────────────────────────────────────── -def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: - """Return True when the skill is compatible with the current OS. - - Skills declare platform requirements via a top-level ``platforms`` list - in their YAML frontmatter:: - - platforms: [macos] # macOS only - platforms: [macos, linux] # macOS and Linux - - If the field is absent or empty the skill is compatible with **all** - platforms (backward-compatible default). - - Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on - older Pythons but became ``"android"`` on Python 3.13+. Termux is a - Linux userland riding on the Android kernel, so skills tagged - ``linux`` are treated as compatible in Termux regardless of which - ``sys.platform`` value Python reports. Individual Linux commands - inside a skill may still misbehave (no systemd, BusyBox utils, no - apt/dnf, etc.) but that is on the skill, not on platform gating. - """ - platforms = frontmatter.get("platforms") +def skill_matches_platform_list(platforms: Any) -> bool: + """Return True when *platforms* is compatible with the current OS.""" if not platforms: return True if not isinstance(platforms, list): @@ -204,6 +185,29 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: return False +def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: + """Return True when the skill is compatible with the current OS. + + Skills declare platform requirements via a top-level ``platforms`` list + in their YAML frontmatter:: + + platforms: [macos] # macOS only + platforms: [macos, linux] # macOS and Linux + + If the field is absent or empty the skill is compatible with **all** + platforms (backward-compatible default). + + Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on + older Pythons but became ``"android"`` on Python 3.13+. Termux is a + Linux userland riding on the Android kernel, so skills tagged + ``linux`` are treated as compatible in Termux regardless of which + ``sys.platform`` value Python reports. Individual Linux commands + inside a skill may still misbehave (no systemd, BusyBox utils, no + apt/dnf, etc.) but that is on the skill, not on platform gating. + """ + return skill_matches_platform_list(frontmatter.get("platforms")) + + # ── Environment matching ────────────────────────────────────────────────── # Recognized environment tags and how each is detected. An environment tag is @@ -787,8 +791,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str): ``SKILL.md`` files, but they are progressive-disclosure data loaded through ``skill_view(..., file_path=...)`` rather than active skill roots. """ - matches = [] - for root, dirs, files in os.walk(skills_dir, followlinks=True): + skills_dir_str = str(skills_dir) + matches: list[str] = [] + for root, dirs, files in os.walk(skills_dir_str, followlinks=True): has_skill_md = "SKILL.md" in files dirs[:] = [ d @@ -797,9 +802,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str): and not (has_skill_md and d in SKILL_SUPPORT_DIRS) ] if filename in files: - matches.append(Path(root) / filename) - for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))): - yield path + matches.append(os.path.join(root, filename)) + for path in sorted(matches): + yield Path(path) # ── Namespace helpers for plugin-provided skills ─────────────────────────── diff --git a/agent/tool_dispatch_helpers.py b/agent/tool_dispatch_helpers.py index 5c9db408b1d..1b6cae98ac6 100644 --- a/agent/tool_dispatch_helpers.py +++ b/agent/tool_dispatch_helpers.py @@ -34,6 +34,7 @@ from typing import Any, Dict, List, Optional from agent.tool_result_classification import ( FILE_MUTATING_TOOL_NAMES as _FILE_MUTATING_TOOLS, ) +from tools.threat_patterns import scan_for_threats logger = logging.getLogger(__name__) @@ -358,7 +359,13 @@ def _trajectory_normalize_msg(msg: Dict[str, Any]) -> Dict[str, Any]: return msg -def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict: +def make_tool_result_message( + name: str, + content: Any, + tool_call_id: str, + *, + effect_disposition: str | None = None, +) -> dict: """Build a tool-result message dict with both the OpenAI-format ``name`` field (required by the wire format and provider adapters) and the internal ``tool_name`` field (written to the session DB messages table). @@ -379,13 +386,23 @@ def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict callers should compare by value, not by ``is``. """ wrapped = _maybe_wrap_untrusted(name, content) - return { + message = { "role": "tool", "name": name, "tool_name": name, "content": wrapped, "tool_call_id": tool_call_id, } + try: + risk_metadata = _tool_output_risk_metadata(name, content) + except Exception as exc: + logger.debug("Tool output risk scan failed for %s: %s", name, exc) + else: + if risk_metadata is not None: + message["_tool_output_risk"] = risk_metadata + if effect_disposition is not None: + message["effect_disposition"] = effect_disposition + return message # Tools whose results carry attacker-controllable content. Wrapping their @@ -419,6 +436,42 @@ def _is_untrusted_tool(name: Optional[str]) -> bool: return any(name.startswith(p) for p in _UNTRUSTED_TOOL_PREFIXES) +def _tool_output_risk_metadata(name: str, content: Any) -> Optional[Dict[str, Any]]: + """Classify textual attacker-controlled output without retaining a copy. + + The advisory metadata is internal-only. It records deterministic finding + identifiers, never blocks or redacts the normal result, and deliberately + omits raw scanned text. + """ + if not _is_untrusted_tool(name): + return None + if isinstance(content, str): + text_parts = [content] + elif isinstance(content, list): + text_parts = [ + item["text"] + for item in content + if isinstance(item, dict) + and item.get("type") == "text" + and isinstance(item.get("text"), str) + ] + if not text_parts: + return None + else: + return None + + findings: List[str] = [] + for text in text_parts: + for finding in scan_for_threats(text, scope="context"): + if finding not in findings: + findings.append(finding) + return { + "risk": "high" if findings else "low", + "findings": findings, + "redacted": False, + } + + def _neutralize_delimiters(content: str) -> str: """Defang any literal ``untrusted_tool_result`` delimiter embedded in attacker-controlled content so it can't break out of the wrapper. diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 9688f5d3dc6..ac505c6d829 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -74,6 +74,25 @@ _MAX_TOOL_WORKERS = 8 _DEFAULT_CONCURRENT_TOOL_TIMEOUT_S = 420.0 +def _parse_tool_arguments(raw_arguments: Any) -> tuple[dict, Optional[str]]: + """Parse model-emitted arguments without repairing or coercing them.""" + try: + arguments = json.loads(raw_arguments) + except (json.JSONDecodeError, TypeError): + arguments = None + if isinstance(arguments, dict): + return arguments, None + return {}, json.dumps( + { + "error": "Invalid tool arguments", + "message": ( + "Tool arguments must be a valid JSON object; tool was not executed." + ), + }, + ensure_ascii=False, + ) + + def _resolve_concurrent_tool_timeout() -> float | None: raw = os.getenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "").strip() if not raw: @@ -324,6 +343,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe tc.function.name, f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]", tc.id, + effect_disposition="none", )) _flush_session_db_after_tool_progress( agent, @@ -337,19 +357,29 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe for tool_call in tool_calls: function_name = tool_call.function.name - # Reset nudge counters + function_args, malformed_args_result = _parse_tool_arguments( + tool_call.function.arguments + ) + + if malformed_args_result is not None: + parsed_calls.append( + ( + tool_call, + function_name, + function_args, + [], + malformed_args_result, + False, + ) + ) + continue + + # Reset nudge counters only for a structurally valid invocation. if function_name == "memory": agent._turns_since_memory = 0 elif function_name == "skill_manage": agent._iters_since_skill = 0 - try: - function_args = json.loads(tool_call.function.arguments) - except json.JSONDecodeError: - function_args = {} - if not isinstance(function_args, dict): - function_args = {} - # ── Tool Search unwrap ──────────────────────────────────────── # When the model invokes the tool_call bridge, peel it open so # every downstream check (checkpointing, guardrails, plugin @@ -798,9 +828,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # deadline snapshot (timed_out_indices, taken from not_done) and this # loop. Prefer that real result over a fabricated timeout message — the # tool genuinely succeeded, just slightly late. + effect_disposition = None if i in timed_out_indices and r is None: suffix = f"{timeout_s:.1f}s" if timeout_s is not None else "the configured timeout" function_result = f"Error executing tool '{name}': timed out after {suffix}" + effect_disposition = "unknown" _emit_terminal_post_tool_call( agent, function_name=name, @@ -847,6 +879,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe tool_duration = 0.0 else: function_name, function_args, function_result, tool_duration, is_error, blocked, middleware_trace = r + if blocked: + effect_disposition = "none" if not blocked: function_result = agent._append_guardrail_observation( @@ -935,7 +969,30 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # image tool result never poisons canonical session history. # String results pass through unchanged. _tool_content = agent._tool_result_content_for_active_model(name, function_result) - messages.append(make_tool_result_message(name, _tool_content, tc.id)) + tool_message = make_tool_result_message( + name, + _tool_content, + tc.id, + effect_disposition=effect_disposition, + ) + messages.append(tool_message) + risk_metadata = tool_message.get("_tool_output_risk") + if ( + risk_metadata is not None + and risk_metadata.get("risk") != "low" + and agent.tool_progress_callback + ): + try: + agent.tool_progress_callback( + "tool.output_risk", + name, + None, + None, + tool_call_id=tc.id, + risk_metadata=risk_metadata, + ) + except Exception as cb_err: + logging.debug("Tool output risk callback error: %s", cb_err) _flush_session_db_after_tool_progress( agent, messages, @@ -980,6 +1037,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe skipped_name, f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]", skipped_tc.id, + effect_disposition="none", )) _flush_session_db_after_tool_progress( agent, @@ -990,13 +1048,24 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe function_name = tool_call.function.name - try: - function_args = json.loads(tool_call.function.arguments) - except json.JSONDecodeError as e: - logger.warning(f"Unexpected JSON error after validation: {e}") - function_args = {} - if not isinstance(function_args, dict): - function_args = {} + function_args, malformed_args_result = _parse_tool_arguments( + tool_call.function.arguments + ) + if malformed_args_result is not None: + messages.append( + make_tool_result_message( + function_name, + malformed_args_result, + tool_call.id, + ) + ) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"invalid tool arguments {function_name}", + ) + agent._apply_pending_steer_to_tool_results(messages, 1) + continue # Tool Search unwrap — see execute_tool_calls_concurrent for full # rationale, including the scope gate (the unwrap dispatches the @@ -1584,7 +1653,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe # Unwrap _multimodal dicts to an OpenAI-style content list # (see parallel path for rationale). String results pass through. _tool_content = agent._tool_result_content_for_active_model(function_name, function_result) - messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id)) + tool_message = make_tool_result_message(function_name, _tool_content, tool_call.id) + messages.append(tool_message) + risk_metadata = tool_message.get("_tool_output_risk") + if ( + risk_metadata is not None + and risk_metadata.get("risk") != "low" + and agent.tool_progress_callback + ): + try: + agent.tool_progress_callback( + "tool.output_risk", + function_name, + None, + None, + tool_call_id=tool_call.id, + risk_metadata=risk_metadata, + ) + except Exception as cb_err: + logging.debug("Tool output risk callback error: %s", cb_err) _flush_session_db_after_tool_progress( agent, messages, @@ -1615,6 +1702,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe skipped_name, f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]", skipped_tc.id, + effect_disposition="none", )) _flush_session_db_after_tool_progress( agent, diff --git a/agent/tool_result_classification.py b/agent/tool_result_classification.py index e136e2964da..c71fc6c31db 100644 --- a/agent/tool_result_classification.py +++ b/agent/tool_result_classification.py @@ -9,6 +9,20 @@ from typing import Any FILE_MUTATING_TOOL_NAMES = frozenset({"write_file", "patch"}) +# Tools whose interrupted/dangling execution is safe to discard because they +# cannot mutate either external state or Hermes session state. Unknown/plugin/ +# MCP tools stay effect-capable by default. +NO_EFFECT_TOOL_NAMES = frozenset({ + "read_file", "search_files", "session_search", "skill_view", "skills_list", + "web_extract", "web_search", "vision_analyze", "browser_snapshot", + "browser_get_images", "browser_console", "read_terminal", +}) + + +def tool_may_have_side_effect(tool_name: str) -> bool: + return tool_name not in NO_EFFECT_TOOL_NAMES + + def file_mutation_result_landed(tool_name: str, result: Any) -> bool: """Return True when a file mutation result proves the write landed.""" if tool_name not in FILE_MUTATING_TOOL_NAMES or not isinstance(result, str): diff --git a/agent/transcription_registry.py b/agent/transcription_registry.py index d84f93b19e4..b04a8593a57 100644 --- a/agent/transcription_registry.py +++ b/agent/transcription_registry.py @@ -44,6 +44,8 @@ _BUILTIN_NAMES = frozenset({ "openai", "mistral", "xai", + "elevenlabs", + "deepinfra", }) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index a7c0207f9bc..218aa269f42 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -9,7 +9,6 @@ which has provider-specific conditionals for max_tokens defaults, reasoning configuration, temperature handling, and extra_body assembly. """ -import copy from typing import Any, Dict from agent.lmstudio_reasoning import resolve_lmstudio_effort @@ -19,6 +18,20 @@ from agent.transports.base import ProviderTransport from agent.transports.types import NormalizedResponse, ToolCall, Usage +def _reasoning_config_for_model(model: str, reasoning_config: dict | None) -> dict | None: + """Return the model's wire-compatible reasoning config.""" + if not isinstance(reasoning_config, dict): + return reasoning_config + if ( + "gpt-5.6" in (model or "").lower() + and str(reasoning_config.get("effort") or "").strip().lower() == "ultra" + ): + normalized = dict(reasoning_config) + normalized["effort"] = "max" + return normalized + return reasoning_config + + def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> dict | None: """Translate Hermes/OpenRouter-style reasoning config to Gemini thinkingConfig.""" if reasoning_config is None or not isinstance(reasoning_config, dict): @@ -53,7 +66,7 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> if normalized_model.startswith("gemini-2.5-"): return thinking_config - if effort not in {"minimal", "low", "medium", "high", "xhigh"}: + if effort not in {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"}: effort = "medium" # Gemini 3 Flash documents low/medium/high thinking levels; Gemini 3 Pro @@ -63,13 +76,13 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> if "flash" in normalized_model: if effort in {"minimal", "low"}: thinking_config["thinkingLevel"] = "low" - elif effort in {"high", "xhigh"}: + elif effort in {"high", "xhigh", "max", "ultra"}: thinking_config["thinkingLevel"] = "high" else: thinking_config["thinkingLevel"] = "medium" elif "pro" in normalized_model: thinking_config["thinkingLevel"] = ( - "high" if effort in {"high", "xhigh"} else "low" + "high" if effort in {"high", "xhigh", "max", "ultra"} else "low" ) return thinking_config @@ -172,6 +185,7 @@ class ChatCompletionsTransport(ProviderTransport): "codex_reasoning_items" in msg or "codex_message_items" in msg or "tool_name" in msg + or "effect_disposition" in msg or "timestamp" in msg # #47868 — strict providers reject this ): needs_sanitize = True @@ -195,27 +209,65 @@ class ChatCompletionsTransport(ProviderTransport): if not needs_sanitize: return messages - sanitized = copy.deepcopy(messages) - for msg in sanitized: + sanitized = list(messages) + for msg_idx, msg in enumerate(messages): if not isinstance(msg, dict): continue - msg.pop("codex_reasoning_items", None) - msg.pop("codex_message_items", None) - msg.pop("tool_name", None) - msg.pop("timestamp", None) # #47868 — leak into strict providers + + copied_msg: dict[str, Any] | None = None + + def mutable_msg() -> dict[str, Any]: + nonlocal copied_msg + if copied_msg is None: + copied_msg = dict(msg) + sanitized[msg_idx] = copied_msg + return copied_msg + + if ( + "codex_reasoning_items" in msg + or "codex_message_items" in msg + or "tool_name" in msg + or "effect_disposition" in msg + or "timestamp" in msg # #47868 — leak into strict providers + ): + out_msg = mutable_msg() + out_msg.pop("codex_reasoning_items", None) + out_msg.pop("codex_message_items", None) + out_msg.pop("tool_name", None) + out_msg.pop("effect_disposition", None) + out_msg.pop("timestamp", None) # #47868 — leak into strict providers + + # Drop all Hermes-internal scaffolding markers (``_``-prefixed). # OpenAI's message schema has no ``_``-prefixed fields, so this # is safe and future-proofs against new markers being added. - for key in [k for k in msg if isinstance(k, str) and k.startswith("_")]: - msg.pop(key, None) + internal_keys = [k for k in msg if isinstance(k, str) and k.startswith("_")] + if internal_keys: + out_msg = mutable_msg() + for key in internal_keys: + out_msg.pop(key, None) + tool_calls = msg.get("tool_calls") if isinstance(tool_calls, list): - for tc in tool_calls: + copied_tool_calls: list[Any] | None = None + for tc_idx, tc in enumerate(tool_calls): if isinstance(tc, dict): - tc.pop("call_id", None) - tc.pop("response_item_id", None) - if strip_extra_content: - tc.pop("extra_content", None) + should_copy_tc = ( + "call_id" in tc + or "response_item_id" in tc + or (strip_extra_content and "extra_content" in tc) + ) + if should_copy_tc: + if copied_tool_calls is None: + copied_tool_calls = list(tool_calls) + copied_tc = dict(tc) + copied_tc.pop("call_id", None) + copied_tc.pop("response_item_id", None) + if strip_extra_content: + copied_tc.pop("extra_content", None) + copied_tool_calls[tc_idx] = copied_tc + if copied_tool_calls is not None: + mutable_msg()["tool_calls"] = copied_tool_calls return sanitized def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -328,7 +380,7 @@ class ChatCompletionsTransport(ProviderTransport): is_nvidia_nim = params.get("is_nvidia_nim", False) is_kimi = params.get("is_kimi", False) is_tokenhub = params.get("is_tokenhub", False) - reasoning_config = params.get("reasoning_config") + reasoning_config = _reasoning_config_for_model(model, params.get("reasoning_config")) if ephemeral is not None and max_tokens_fn: api_kwargs.update(max_tokens_fn(ephemeral)) @@ -527,7 +579,7 @@ class ChatCompletionsTransport(ProviderTransport): api_kwargs["max_tokens"] = anthropic_max # Provider-specific api_kwargs extras (reasoning_effort, metadata, etc.) - reasoning_config = params.get("reasoning_config") + reasoning_config = _reasoning_config_for_model(model, params.get("reasoning_config")) extra_body_from_profile, top_level_from_profile = ( profile.build_api_kwargs_extras( reasoning_config=reasoning_config, diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 56374b87533..56f25963222 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -67,9 +67,9 @@ class ResponsesApiTransport(ProviderTransport): """Classify the current Responses endpoint from transport params.""" from agent.codex_responses_adapter import _classify_responses_issuer return _classify_responses_issuer( - is_xai_responses=bool(params.get("is_xai_responses")), - is_github_responses=bool(params.get("is_github_responses")), - is_codex_backend=bool(params.get("is_codex_backend")), + is_xai_responses=params.get("is_xai_responses") is True, + is_github_responses=params.get("is_github_responses") is True, + is_codex_backend=params.get("is_codex_backend") is True, base_url=params.get("base_url"), ) @@ -80,7 +80,8 @@ class ResponsesApiTransport(ProviderTransport): self._last_issuer_kind = issuer return _chat_messages_to_responses_input( messages, - is_xai_responses=bool(kwargs.get("is_xai_responses")), + is_xai_responses=kwargs.get("is_xai_responses") is True, + is_github_responses=kwargs.get("is_github_responses") is True, replay_encrypted_reasoning=bool( kwargs.get("replay_encrypted_reasoning", True) ), @@ -137,9 +138,9 @@ class ResponsesApiTransport(ProviderTransport): if not instructions: instructions = DEFAULT_AGENT_IDENTITY - is_github_responses = params.get("is_github_responses", False) - is_codex_backend = params.get("is_codex_backend", False) - is_xai_responses = params.get("is_xai_responses", False) + is_github_responses = params.get("is_github_responses") is True + is_codex_backend = params.get("is_codex_backend") is True + is_xai_responses = params.get("is_xai_responses") is True replay_encrypted_reasoning = bool( params.get("replay_encrypted_reasoning", True) ) @@ -163,6 +164,12 @@ class ResponsesApiTransport(ProviderTransport): reasoning_effort = reasoning_config["effort"] _effort_clamp = {"minimal": "low"} + if "gpt-5.6" in (model or "").lower(): + # Ultra is the Codex product tier; the Responses API wire value is max. + _effort_clamp["ultra"] = "max" + if params.get("is_xai_responses", False): + # xAI Responses tops out at high; keep generic stronger values usable. + _effort_clamp.update({"xhigh": "high", "max": "high", "ultra": "high"}) reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort) response_tools = _responses_tools(tools) @@ -239,6 +246,7 @@ class ResponsesApiTransport(ProviderTransport): "input": _chat_messages_to_responses_input( payload_messages, is_xai_responses=is_xai_responses, + is_github_responses=is_github_responses, replay_encrypted_reasoning=replay_encrypted_reasoning, current_issuer_kind=issuer_kind, ), @@ -438,13 +446,23 @@ class ResponsesApiTransport(ProviderTransport): return False return True - def preflight_kwargs(self, api_kwargs: Any, *, allow_stream: bool = False) -> dict: + def preflight_kwargs( + self, + api_kwargs: Any, + *, + allow_stream: bool = False, + is_github_responses: bool = False, + ) -> dict: """Validate and sanitize Codex API kwargs before the call. Normalizes input items, strips unsupported fields, validates structure. """ from agent.codex_responses_adapter import _preflight_codex_api_kwargs - return _preflight_codex_api_kwargs(api_kwargs, allow_stream=allow_stream) + return _preflight_codex_api_kwargs( + api_kwargs, + allow_stream=allow_stream, + is_github_responses=is_github_responses, + ) def map_finish_reason(self, raw_reason: str) -> str: """Map Codex response.status to OpenAI finish_reason. diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 78af728711d..e7600f3be0d 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -755,22 +755,22 @@ class CodexAppServerSession: turn_obj = (note.get("params") or {}).get("turn") or {} result.turn_id = turn_obj.get("id") or result.turn_id turn_status = turn_obj.get("status") - if turn_status and turn_status not in {"completed", "interrupted"}: + if turn_status == "interrupted": + result.interrupted = True + result.error = result.error or "compact turn interrupted" + elif turn_status and turn_status != "completed": err_obj = turn_obj.get("error") - if err_obj: - err_msg = _format_responses_error(err_obj, str(turn_status)) - stderr_blob = "\n".join( - self._client.stderr_tail(40) + err_msg = _format_responses_error(err_obj, str(turn_status)) + stderr_blob = "\n".join(self._client.stderr_tail(40)) + hint = _classify_oauth_failure(err_msg, stderr_blob) + if hint is not None: + result.error = hint + result.should_retire = True + else: + result.error = self._format_error_with_stderr( + f"compact turn ended status={turn_status}", + err_msg, ) - hint = _classify_oauth_failure(err_msg, stderr_blob) - if hint is not None: - result.error = hint - result.should_retire = True - else: - result.error = self._format_error_with_stderr( - f"compact turn ended status={turn_status}", - err_msg, - ) if not turn_complete and not result.interrupted: self._issue_interrupt(result.turn_id) diff --git a/agent/tts_registry.py b/agent/tts_registry.py index 7cf6e6cb00a..a43359ec595 100644 --- a/agent/tts_registry.py +++ b/agent/tts_registry.py @@ -56,6 +56,7 @@ _BUILTIN_NAMES = frozenset({ "neutts", "kittentts", "piper", + "deepinfra", }) diff --git a/agent/turn_context.py b/agent/turn_context.py index 4fd6ddff2c3..a5e738588e4 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -319,6 +319,20 @@ def build_turn_context( current_turn_user_idx = len(messages) - 1 agent._persist_user_message_idx = current_turn_user_idx + # Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot) + # and notify the host so it can play hearts. Token-free, never touches the + # conversation, and never fatal — a purely optional UI beat. + reaction_callback = getattr(agent, "reaction_callback", None) + if reaction_callback is not None: + try: + from agent.reactions import detect_reaction + + kind = detect_reaction(original_user_message) + if kind: + reaction_callback(kind) + except Exception: + pass + if not agent.quiet_mode: _print_preview = summarize_user_message_for_log(user_message) agent._safe_print( diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 5eaad31848c..1adc3c6c363 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -42,6 +42,7 @@ def finalize_turn( original_user_message, _should_review_memory, _turn_exit_reason, + _pending_verification_response=None, ): """Run the post-loop finalization and return the turn ``result`` dict. @@ -50,10 +51,35 @@ def finalize_turn( """ from agent.conversation_loop import logger - if final_response is None and ( + budget_exhausted = ( api_call_count >= agent.max_iterations or agent.iteration_budget.remaining <= 0 - ): + ) + budget_fallback_eligible = ( + budget_exhausted + and not interrupted + and not failed + and str(_turn_exit_reason) in {"unknown", "budget_exhausted"} + ) + continuation_budget_exhausted = ( + final_response is None + and bool(_pending_verification_response) + and budget_fallback_eligible + ) + + iteration_limit_fallback = False + preserved_verification_fallback = False + if continuation_budget_exhausted: + # A verification/continuation gate deliberately withheld a composed + # answer, then consumed the remaining budget before producing a newer + # one. Preserve that exact answer instead of replacing it with another + # fallible model call. The explicit pending value is the provenance + # guard: unrelated error/recovery exits can never enter this branch. + final_response = _pending_verification_response + _turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})" + iteration_limit_fallback = True + preserved_verification_fallback = True + elif final_response is None and budget_fallback_eligible: # Budget exhausted — ask the model for a summary via one extra # API call with tools stripped. _handle_max_iterations injects a # user message and makes a single toolless request. @@ -68,20 +94,18 @@ def finalize_turn( "— requesting summary..." ) final_response = agent._handle_max_iterations(messages, api_call_count) + iteration_limit_fallback = True + if iteration_limit_fallback: # If running as a kanban worker, signal the dispatcher that the # worker could not complete (rather than treating it as a - # protocol violation). The agent loop strips tools before calling - # _handle_max_iterations, so the model cannot call kanban_block - # itself — we must do it on its behalf. + # protocol violation). This applies whether the user-facing fallback + # came from the summary call or an explicitly pending continuation; + # both exhausted the task budget and must advance the failure circuit. # # We route through ``_record_task_failure(outcome="timed_out")`` - # rather than ``kanban_block`` so this counts toward the - # ``consecutive_failures`` counter and the dispatcher's - # ``failure_limit`` circuit breaker (#29747 gap 2). Without this, - # a task whose worker keeps exhausting its budget would block - # silently each run, get auto-promoted by the operator (or never - # surface), and re-block in an endless loop with no signal. + # rather than ``kanban_block`` so this counts toward the dispatcher's + # consecutive-failure circuit breaker (#29747 gap 2). _kanban_task = os.environ.get("HERMES_KANBAN_TASK") if _kanban_task: try: @@ -304,6 +328,7 @@ def finalize_turn( # truncated partial (the "The" case from #34452). _is_partial_fragment = ( not _is_empty_terminal + and not preserved_verification_fallback and not str(_turn_exit_reason).startswith("text_response") and len(_stripped) <= 24 and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"} @@ -424,6 +449,11 @@ def finalize_turn( "estimated_cost_usd": agent.session_estimated_cost_usd, "cost_status": agent.session_cost_status, "cost_source": agent.session_cost_source, + # Requested service tier (from request_overrides.extra_body), for + # billing audits by callers like `hermes -z --usage-file`. + "service_tier": ( + (getattr(agent, "request_overrides", {}) or {}).get("extra_body") or {} + ).get("service_tier"), "session_id": agent.session_id, } if agent._tool_guardrail_halt_decision is not None: diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index d7b56a9fac4..aa306fa12f8 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -103,6 +103,54 @@ _UTC_NOW = lambda: datetime.now(timezone.utc) # Official docs snapshot entries. Models whose published pricing and cache # semantics are stable enough to encode exactly. _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { + # ── OpenAI GPT-5.6 series (Sol/Terra/Luna) ─────────────────────────── + # Announced in limited preview 2026-06-26; GA 2026-07-09 at the same + # rates (Sol $5/$30, Terra $2.50/$15, Luna $1/$6 per 1M in/out). Cache + # writes are billed at 1.25x the uncached input rate; cache reads get the + # standard 90% discount (0.10x input, confirmed: Sol $0.50/M cached). + # Note: "Sol Fast mode" ($12.5/$75, up to 750 tok/s via Cerebras) is a + # separate serving tier, not covered by these entries. The "-pro" + # variants (high-effort modes, GA alongside base tiers) bill at the + # SAME per-token rates and are aliased onto these entries below the + # dict (they cost more per task by consuming more tokens, not by a + # higher rate — verified against OpenRouter's live pricing 2026-07-09). + # Source: https://openai.com/index/previewing-gpt-5-6-sol/ + ( + "openai", + "gpt-5.6-sol", + ): PricingEntry( + input_cost_per_million=Decimal("5.00"), + output_cost_per_million=Decimal("30.00"), + cache_read_cost_per_million=Decimal("0.50"), + cache_write_cost_per_million=Decimal("6.25"), + source="official_docs_snapshot", + source_url="https://openai.com/index/previewing-gpt-5-6-sol/", + pricing_version="openai-gpt-5.6-2026-07", + ), + ( + "openai", + "gpt-5.6-terra", + ): PricingEntry( + input_cost_per_million=Decimal("2.50"), + output_cost_per_million=Decimal("15.00"), + cache_read_cost_per_million=Decimal("0.25"), + cache_write_cost_per_million=Decimal("3.125"), + source="official_docs_snapshot", + source_url="https://openai.com/index/previewing-gpt-5-6-sol/", + pricing_version="openai-gpt-5.6-2026-07", + ), + ( + "openai", + "gpt-5.6-luna", + ): PricingEntry( + input_cost_per_million=Decimal("1.00"), + output_cost_per_million=Decimal("6.00"), + cache_read_cost_per_million=Decimal("0.10"), + cache_write_cost_per_million=Decimal("1.25"), + source="official_docs_snapshot", + source_url="https://openai.com/index/previewing-gpt-5-6-sol/", + pricing_version="openai-gpt-5.6-2026-07", + ), # ── Anthropic Claude 4.8 ───────────────────────────────────────────── # Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate # model ID with 2x premium (vs the 6x premium on older Opus generations). @@ -563,6 +611,15 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { ), } +# GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as +# their base tiers (more tokens per task, not a higher rate). Alias them +# onto the base entries so the snapshot stays single-source. +for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"): + _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[ + ("openai", _base_56) + ] +del _base_56 + def _to_decimal(value: Any) -> Optional[Decimal]: if value is None: @@ -602,7 +659,11 @@ def resolve_billing_route( return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api") if provider_name == "anthropic": return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") - if provider_name == "openai": + # "openai-api" is the picker/registry slug for direct api.openai.com; it + # bills identically to bare "openai", so normalize it here — otherwise the + # ("openai", ) _OFFICIAL_DOCS_PRICING keys are unreachable from the + # openai-api provider path. + if provider_name in {"openai", "openai-api"}: return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") if provider_name in {"minimax", "minimax-cn"}: return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") diff --git a/agent/verification_stop.py b/agent/verification_stop.py index 605d58d3a7d..1f68b1aace5 100644 --- a/agent/verification_stop.py +++ b/agent/verification_stop.py @@ -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 " diff --git a/agent/video_gen_provider.py b/agent/video_gen_provider.py index af8bf9faf78..8630f8f204b 100644 --- a/agent/video_gen_provider.py +++ b/agent/video_gen_provider.py @@ -244,6 +244,78 @@ def save_bytes_video( return path +_URL_VIDEO_CONTENT_TYPES = { + "video/mp4": "mp4", + "video/webm": "webm", + "video/quicktime": "mov", + "video/x-matroska": "mkv", +} + + +def save_url_video( + url: str, + *, + prefix: str = "video", + timeout: float = 180.0, + max_bytes: int = 200 * 1024 * 1024, +) -> Path: + """Download a video URL and write it under ``$HERMES_HOME/cache/videos/``. + + The video twin of :func:`agent.image_gen_provider.save_url_image`: several + backends (DeepInfra, FAL) return an *ephemeral* delivery URL that expires + before a downstream consumer can fetch it, so we materialise the bytes + locally at tool-completion time. Streams with a size cap. + + Raises on any network / HTTP / oversize error so callers can fall back to + returning the bare URL. + """ + import requests + + response = requests.get(url, timeout=timeout, stream=True) + response.raise_for_status() + + content_type = (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() + extension = _URL_VIDEO_CONTENT_TYPES.get(content_type) + if extension is None: + url_path = url.split("?", 1)[0].lower() + for ext in ("mp4", "webm", "mov", "mkv"): + if url_path.endswith(f".{ext}"): + extension = ext + break + if extension is None: + extension = "mp4" + + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + short = uuid.uuid4().hex[:8] + path = _videos_cache_dir() / f"{prefix}_{ts}_{short}.{extension}" + + bytes_written = 0 + with path.open("wb") as fh: + for chunk in response.iter_content(chunk_size=256 * 1024): + if not chunk: + continue + bytes_written += len(chunk) + if bytes_written > max_bytes: + fh.close() + try: + path.unlink() + except OSError: + pass + raise ValueError( + f"Video at {url} exceeds {max_bytes // (1024 * 1024)}MB cap; refusing to cache." + ) + fh.write(chunk) + + if bytes_written == 0: + try: + path.unlink() + except OSError: + pass + raise ValueError(f"Video at {url} was empty (0 bytes).") + + return path + + def success_response( *, video: str, @@ -297,3 +369,222 @@ def error_response( "aspect_ratio": aspect_ratio, "provider": provider, } + + +# --------------------------------------------------------------------------- +# Reusable OpenAI-compatible backend +# --------------------------------------------------------------------------- + + +class OpenAICompatibleVideoGenProvider(VideoGenProvider): + """Generic text/image-to-video over the OpenAI ``client.videos`` API. + + DeepInfra, OpenAI/Sora, and OpenRouter all expose the same + ``POST /videos`` async-job shape (``create`` → poll → ``download_content``), + so the SDK call lives here once. A concrete backend only needs to declare + its identity and credentials:: + + class FooVideoGenProvider(OpenAICompatibleVideoGenProvider): + name = "foo" + _env_key = "FOO_API_KEY" + _default_base_url = "https://api.foo.com/v1/openai" + def list_models(self): + return [...] # entries with an "id" key; default_model() uses [0] + + ``image_url`` routes to image-to-video; its absence routes to text-to-video. + Provider-specific fields (``image_url``/``negative_prompt``/``seed``) ride + in ``extra_body`` so they pass through the SDK unchanged. + """ + + _env_key: str = "OPENAI_API_KEY" + _default_base_url: str = "https://api.openai.com/v1" + + # Polling cadence for the async video job. The OpenAI SDK's + # ``create_and_poll`` defaults to ~1 poll/second and loops forever on a + # non-terminal status, so a multi-minute job issues hundreds of sequential + # requests and a stuck job pins its tool-executor worker thread with no way + # out. We hand-roll a bounded poll instead: a coarse interval plus a hard + # wall-clock deadline that surfaces a timeout error. + _poll_interval_s: float = 5.0 + _poll_deadline_s: float = 900.0 + + def _api_key(self) -> str: + import os + + return os.environ.get(self._env_key, "").strip() + + def is_available(self) -> bool: + return bool(self._api_key()) + + def _create_and_poll(self, client: Any, call_kwargs: Dict[str, Any]) -> Any: + """Create the video job and poll to completion with a hard deadline. + + Replaces ``client.videos.create_and_poll`` (unbounded 1/s loop) with a + coarse interval and a wall-clock cap. Returns the terminal video object + (any status); raises :class:`TimeoutError` if the deadline passes + first. + """ + import time + + video = client.videos.create(**call_kwargs) + terminal = {"completed", "succeeded", "failed", "error", "cancelled", "canceled"} + deadline = time.monotonic() + self._poll_deadline_s + while getattr(video, "status", None) not in terminal: + if time.monotonic() >= deadline: + raise TimeoutError( + f"video job {getattr(video, 'id', '?')} did not reach a terminal " + f"status within {int(self._poll_deadline_s)}s " + f"(last status={getattr(video, 'status', None)!r})" + ) + time.sleep(self._poll_interval_s) + video = client.videos.retrieve(video.id) + return video + + def _base_url(self) -> str: + import os + + override = os.environ.get(f"{self.name.upper()}_BASE_URL", "").strip() + return override or self._default_base_url + + def generate( + self, + prompt: str, + *, + model: Optional[str] = None, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, + duration: Optional[int] = None, + aspect_ratio: str = DEFAULT_ASPECT_RATIO, + resolution: str = DEFAULT_RESOLUTION, + negative_prompt: Optional[str] = None, + audio: Optional[bool] = None, + seed: Optional[int] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + if not prompt or not prompt.strip(): + return error_response( + error="prompt is required", error_type="invalid_request", provider=self.name + ) + if not self._api_key(): + return error_response( + error=f"{self._env_key} is not set", + error_type="missing_credentials", + provider=self.name, + ) + try: + import openai + except ImportError: + return error_response( + error="openai Python package not installed (pip install openai)", + error_type="missing_dependency", + provider=self.name, + ) + + model_id = model or self.default_model() + if not model_id: + return error_response( + error=f"no {self.name} video model available (live catalog empty?)", + error_type="no_model", + provider=self.name, + ) + + # Provider-specific fields the OpenAI ``videos.create`` signature does + # not name natively — pass them through ``extra_body``. + extra_body = { + k: v + for k, v in { + "negative_prompt": negative_prompt, + "aspect_ratio": aspect_ratio, + "image_url": image_url, # presence ⇒ image-to-video + "seed": seed, + }.items() + if v is not None + } + call_kwargs: Dict[str, Any] = {"model": model_id, "prompt": prompt} + if duration: + call_kwargs["seconds"] = str(duration) + if resolution: + call_kwargs["size"] = resolution + if extra_body: + call_kwargs["extra_body"] = extra_body + + client = openai.OpenAI(api_key=self._api_key(), base_url=self._base_url()) + try: + try: + video = self._create_and_poll(client, call_kwargs) + except Exception as exc: # noqa: BLE001 - surface any SDK/API/timeout failure uniformly + logger.debug("%s video generation failed", self.name, exc_info=True) + return error_response( + error=f"{self.name} video generation failed: {exc}", + error_type="api_error", + provider=self.name, + model=model_id, + prompt=prompt, + aspect_ratio=aspect_ratio, + ) + + # Terminal success status differs across backends: DeepInfra reports + # "succeeded", OpenAI/Sora reports "completed". Accept both. + status = getattr(video, "status", None) + if status not in ("completed", "succeeded"): + # ``video.error`` is a structured SDK object (pydantic + # VideoCreateError), not a string — str() it so the response + # dict stays JSON-serializable for the tool layer. + job_error = getattr(video, "error", None) + return error_response( + error=str(job_error) if job_error else f"video job ended with status={status!r}", + error_type="job_failed", + provider=self.name, + model=model_id, + prompt=prompt, + aspect_ratio=aspect_ratio, + ) + + # Resolve the output. Providers expose it either as a delivery URL in + # the job's ``data`` list (DeepInfra, FAL-style) or only via the SDK + # download endpoint (OpenAI/Sora). Download the bytes and save locally + # so the caller gets a durable file — DeepInfra's delivery URLs in + # particular are short-lived. Matches plugins/image_gen/deepinfra. + url = None + for item in getattr(video, "data", None) or []: + candidate = item.get("url") if isinstance(item, dict) else getattr(item, "url", None) + if candidate: + url = candidate + break + + try: + if url: + # Materialise the (often short-lived) delivery URL locally. + video_ref = str(save_url_video(url, prefix=self.name)) + else: + # OpenAI/Sora style: no public URL — pull bytes via the SDK. + raw = client.videos.download_content(video.id).read() + video_ref = str(save_bytes_video(raw, prefix=self.name)) + except Exception as exc: # noqa: BLE001 + if url: + # Best-effort: hand back the URL rather than fail outright. + logger.debug("%s: saving video locally failed (%s); returning URL", self.name, exc) + video_ref = url + else: + return error_response( + error=f"{self.name} video job succeeded but no output could be retrieved: {exc}", + error_type="empty_response", + provider=self.name, + model=model_id, + prompt=prompt, + aspect_ratio=aspect_ratio, + ) + + return success_response( + video=video_ref, + model=model_id, + prompt=prompt, + modality="image" if image_url else "text", + aspect_ratio=aspect_ratio, + duration=duration or 0, + provider=self.name, + ) + finally: + close = getattr(client, "close", None) + if callable(close): + close() diff --git a/agent/video_gen_registry.py b/agent/video_gen_registry.py index ad936e29d42..c4d28e39ed4 100644 --- a/agent/video_gen_registry.py +++ b/agent/video_gen_registry.py @@ -11,12 +11,15 @@ Active selection The active provider is chosen by ``video_gen.provider`` in ``config.yaml``. If unset, :func:`get_active_provider` applies fallback logic: -1. If exactly one provider is registered, use it. +1. If exactly one *available* provider is registered, use it. 2. Otherwise return ``None`` (the tool surfaces a helpful error pointing the user at ``hermes tools``). Mirrors ``agent/image_gen_registry.py`` so the two surfaces behave the -same. +same: the unconfigured fallback is filtered by ``is_available()`` so a box +that has credentials for only one backend (e.g. DeepInfra, while the +``fal``/``xai`` plugins also register unconditionally) auto-selects it +instead of returning ``None``. """ from __future__ import annotations @@ -100,13 +103,26 @@ def get_active_provider() -> Optional[VideoGenProvider]: if provider is not None: return provider logger.debug( - "video_gen.provider='%s' configured but not registered; falling back", + "video_gen.provider='%s' configured but not registered; failing closed", configured, ) + return None - # Fallback: single-provider case - if len(snapshot) == 1: - return next(iter(snapshot.values())) + def _is_available_safe(p: VideoGenProvider) -> bool: + """Wrap ``is_available()`` so a buggy provider doesn't kill resolution.""" + try: + return bool(p.is_available()) + except Exception as exc: # noqa: BLE001 + logger.debug("video_gen provider %s.is_available() raised %s", p.name, exc) + return False + + # Fallback: single *available* provider — filter by is_available() so a + # box with credentials for only one backend auto-selects it even when + # other providers (fal/xai) register unconditionally without keys. + # Mirrors agent/image_gen_registry.get_active_provider(). + available = [p for p in snapshot.values() if _is_available_safe(p)] + if len(available) == 1: + return available[0] return None diff --git a/apps/bootstrap-installer/package.json b/apps/bootstrap-installer/package.json index 4638a8c905e..7550e3c7c98 100644 --- a/apps/bootstrap-installer/package.json +++ b/apps/bootstrap-installer/package.json @@ -12,7 +12,8 @@ "tauri:dev": "tauri dev", "tauri:build": "tauri build", "tauri:build:debug": "tauri build --debug", - "typecheck": "tsc -p . --noEmit" + "typecheck": "tsc -p . --noEmit", + "check": "npm run typecheck" }, "dependencies": { "@nous-research/ui": "0.16.0", diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs index a8fcd656b8a..1d70ec59a61 100644 --- a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -1,6 +1,6 @@ //! Bootstrap orchestration. //! -//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`. +//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.ts`. //! Drives install.ps1 / install.sh stage-by-stage, emits progress events //! over the Tauri `bootstrap` channel, writes a forensic log to //! HERMES_HOME/logs/bootstrap-.log. diff --git a/apps/bootstrap-installer/src-tauri/src/events.rs b/apps/bootstrap-installer/src-tauri/src/events.rs index e00105013be..afadbf8e868 100644 --- a/apps/bootstrap-installer/src-tauri/src/events.rs +++ b/apps/bootstrap-installer/src-tauri/src/events.rs @@ -1,6 +1,6 @@ //! Event types streamed from Rust → React. //! -//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape +//! These mirror `apps/desktop/electron/bootstrap-runner.ts`'s event shape //! 1:1 so the React installer code can be roughly identical to the Electron //! install-overlay we'll replace. //! diff --git a/apps/bootstrap-installer/src-tauri/src/install_script.rs b/apps/bootstrap-installer/src-tauri/src/install_script.rs index 217ee9fef5a..67a114408f8 100644 --- a/apps/bootstrap-installer/src-tauri/src/install_script.rs +++ b/apps/bootstrap-installer/src-tauri/src/install_script.rs @@ -8,7 +8,7 @@ //! 3. Network: download from GitHub raw at a pinned commit or branch. //! Commit pins are immutable; branch pins are HEAD-tracking. //! -//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`, +//! Mirrors `apps/desktop/electron/bootstrap-runner.ts`'s `resolveInstallScript`, //! but the dev-checkout resolution is driven by an env var rather than the //! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant //! to live OUTSIDE any repo checkout. @@ -64,7 +64,7 @@ impl ScriptKind { } /// Validates a string looks like a git SHA (7+ hex chars). Mirrors -/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs. +/// `STAMP_COMMIT_RE` from bootstrap-runner.ts. fn is_valid_commit(s: &str) -> bool { let len = s.len(); (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit()) diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs index 99ad16f6b88..7c64c91cf6e 100644 --- a/apps/bootstrap-installer/src-tauri/src/paths.rs +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -150,7 +150,7 @@ fn repair_macos_installer_helper(path: &Path) { fn repair_macos_installer_helper(_path: &Path) {} /// Where install.ps1 writes the bootstrap-complete marker (existence-only file -/// the Electron app also checks). Per main.cjs: +/// the Electron app also checks). Per main.ts: /// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete') /// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so /// this is a probe helper, not a definitive path. diff --git a/apps/bootstrap-installer/src-tauri/src/powershell.rs b/apps/bootstrap-installer/src-tauri/src/powershell.rs index f37a3c68b36..04694c113b5 100644 --- a/apps/bootstrap-installer/src-tauri/src/powershell.rs +++ b/apps/bootstrap-installer/src-tauri/src/powershell.rs @@ -1,6 +1,6 @@ //! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh. //! -//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same +//! Port of `spawnPowerShell` from bootstrap-runner.ts, with the same //! line-buffered stdout/stderr streaming + cancellation semantics. //! //! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File ' @@ -39,9 +40,11 @@ test('dashboardIndexUrl preserves dashboard path prefixes', () => { test('resolveServedDashboardToken uses the served token and logs when it differs', async () => { const logs = [] + const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { fetchText: async url => { assert.equal(url, 'http://127.0.0.1:9120/') + return '' }, rememberLog: line => logs.push(line) @@ -100,8 +103,9 @@ test('isForeignBackendToken only flags a mismatched token from a dead child', () [{ servedToken: null, spawnToken: 'mine', childAlive: false }, false], [{ servedToken: '', spawnToken: 'mine', childAlive: false }, false] ] + for (const [input, expected] of cases) { - assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input)) + assert.equal(isForeignBackendToken(input as any), expected, JSON.stringify(input)) } }) @@ -128,6 +132,7 @@ test('adoptServedDashboardToken refuses a foreign token when our child is dead', test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => { const logs = [] + const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { childAlive: () => true, fetchText: async () => { diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.ts similarity index 93% rename from apps/desktop/electron/dashboard-token.cjs rename to apps/desktop/electron/dashboard-token.ts index 1a9ca50ad9c..42f21485343 100644 --- a/apps/desktop/electron/dashboard-token.cjs +++ b/apps/desktop/electron/dashboard-token.ts @@ -9,29 +9,39 @@ const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000 -async function fetchPublicText(url, options = {}) { +async function fetchPublicText(url, options: any = {}) { const { protocol } = new URL(url) + if (protocol !== 'http:' && protocol !== 'https:') { throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`) } const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => { if (error.name === 'TimeoutError') { throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`) } + throw error }) + const text = await res.text() - if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`) + if (!res.ok) { + throw new Error(`${res.status}: ${text || res.statusText}`) + } return text } function extractInjectedDashboardToken(html) { const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || '')) - if (!match) return null + + if (!match) { + return null + } + try { return JSON.parse(match[1]) } catch { @@ -43,11 +53,13 @@ function dashboardIndexUrl(baseUrl) { return `${String(baseUrl || '').replace(/\/+$/, '')}/` } -async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) { +async function resolveServedDashboardToken(baseUrl, fallbackToken, options: any = {}) { const fetchText = options.fetchText || fetchPublicText + const html = await fetchText(dashboardIndexUrl(baseUrl), { timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS }) + const servedToken = extractInjectedDashboardToken(html) if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') { @@ -76,6 +88,7 @@ function isForeignBackendToken({ servedToken, spawnToken, childAlive }) { async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) { const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => { options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`) + return spawnToken }) @@ -88,10 +101,10 @@ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, labe return servedToken } -module.exports = { - DEFAULT_TOKEN_FETCH_TIMEOUT_MS, +export { adoptServedDashboardToken, dashboardIndexUrl, + DEFAULT_TOKEN_FETCH_TIMEOUT_MS, extractInjectedDashboardToken, fetchPublicText, isForeignBackendToken, diff --git a/apps/desktop/electron/desktop-uninstall.test.cjs b/apps/desktop/electron/desktop-uninstall.test.ts similarity index 97% rename from apps/desktop/electron/desktop-uninstall.test.cjs rename to apps/desktop/electron/desktop-uninstall.test.ts index 15a864b7c4f..6d296ee1db9 100644 --- a/apps/desktop/electron/desktop-uninstall.test.cjs +++ b/apps/desktop/electron/desktop-uninstall.test.ts @@ -1,7 +1,7 @@ /** - * Tests for electron/desktop-uninstall.cjs. + * Tests for electron/desktop-uninstall.ts. * - * Run with: node --test electron/desktop-uninstall.test.cjs + * Run with: node --test electron/desktop-uninstall.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * These are the pure helpers behind the desktop Chat GUI uninstaller: the @@ -9,19 +9,20 @@ * cleanup-script builders (POSIX + Windows). */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' -const { - UNINSTALL_MODES, +import { test } from 'vitest' + +import { buildPosixCleanupScript, buildWindowsCleanupScript, modeRemovesAgent, modeRemovesUserData, resolveRemovableAppPath, shouldRemoveAppBundle, + UNINSTALL_MODES, uninstallArgsForMode -} = require('./desktop-uninstall.cjs') +} from './desktop-uninstall' // --- uninstallArgsForMode --- @@ -132,6 +133,7 @@ test('buildPosixCleanupScript waits for the PID, runs the uninstall module, remo appPath: '/opt/hermes/linux-unpacked', hermesHome: '/home/x/.hermes' }) + assert.match(script, /^#!\/bin\/bash/) assert.match(script, /pid=4321/) assert.match(script, /kill -0 "\$pid"/) @@ -152,6 +154,7 @@ test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/fu appPath: null, hermesHome: '/home/x/.hermes' }) + // System python + source on PYTHONPATH so import hermes_cli works while the // venv is torn down. assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/) @@ -168,6 +171,7 @@ test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', ( appPath: null, hermesHome: '/h' }) + assert.doesNotMatch(script, /export PYTHONPATH/) }) @@ -181,6 +185,7 @@ test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => { appPath: null, hermesHome: '/h' }) + assert.doesNotMatch(script, /rm -rf '\//) // Still runs the uninstall. assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/) @@ -196,6 +201,7 @@ test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () = appPath: null, hermesHome: '/h' }) + // The apostrophe is closed-escaped-reopened so the shell sees the literal. assert.match(script, /'\/home\/o'\\''brien\/python'/) }) @@ -212,6 +218,7 @@ test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir b appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes', hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes' }) + assert.match(script, /@echo off/) assert.match(script, /set "PID=9988"/) // PYTHONPATH set so a system python can import hermes_cli from source. @@ -238,6 +245,7 @@ test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, n appPath: null, hermesHome: 'C:\\h' }) + assert.doesNotMatch(script, /rmdir/) assert.doesNotMatch(script, /set "PYTHONPATH=/) }) diff --git a/apps/desktop/electron/desktop-uninstall.cjs b/apps/desktop/electron/desktop-uninstall.ts similarity index 93% rename from apps/desktop/electron/desktop-uninstall.cjs rename to apps/desktop/electron/desktop-uninstall.ts index 01b756acd1e..3d644865382 100644 --- a/apps/desktop/electron/desktop-uninstall.cjs +++ b/apps/desktop/electron/desktop-uninstall.ts @@ -1,14 +1,14 @@ /** - * desktop-uninstall.cjs + * desktop-uninstall.ts * * Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map * the three user-facing uninstall modes to the `hermes uninstall` CLI flags, * resolve the running app bundle/exe so a detached cleanup script can remove * it after the app quits, and build that cleanup script for each OS. * - * Kept standalone (no `require('electron')`) so it can be unit-tested with - * `node --test` — same pattern as connection-config.cjs / backend-probes.cjs. - * main.cjs requires these and wires them into the electron-coupled IPC layer. + * Kept standalone (no ` import 'electron'`) so it can be unit-tested with + * `node --test` — same pattern as connection-config.ts / backend-probes.ts. + * main.ts requires these and wires them into the electron-coupled IPC layer. * * The three modes mirror the CLI's options exactly: * - 'gui' → remove ONLY the Chat GUI, keep the agent + all user data. @@ -23,10 +23,10 @@ * app bundle (locked on macOS/Windows while the process is alive). So we hand * the work to a detached child that waits for this app's PID to exit, runs the * Python uninstall, then removes the app bundle — then the app quits. Same - * shape as the self-update swap-and-relaunch flow already in main.cjs. + * shape as the self-update swap-and-relaunch flow already in main.ts. */ -const path = require('node:path') +import path from 'node:path' const UNINSTALL_MODES = ['gui', 'lite', 'full'] @@ -41,6 +41,7 @@ function uninstallArgsForMode(mode) { if (!UNINSTALL_MODES.includes(mode)) { throw new Error(`Unknown uninstall mode: ${mode}`) } + return ['-m', 'hermes_cli.uninstall', '--mode', mode] } @@ -65,9 +66,12 @@ function modeRemovesUserData(mode) { * Returns null when we can't confidently identify a removable bundle (e.g. * running from a dev checkout, or a system-package install we must not rmtree). */ -function resolveRemovableAppPath(execPath, platform, env = {}) { +function resolveRemovableAppPath(execPath, platform, env: any = {}) { const exe = String(execPath || '') - if (!exe) return null + + if (!exe) { + return null + } // Use the path flavor that matches the TARGET platform, not the host running // this code — so the Windows branch parses backslash paths correctly even @@ -79,22 +83,37 @@ function resolveRemovableAppPath(execPath, platform, env = {}) { const macOsDir = p.dirname(exe) // …/Contents/MacOS const contents = p.dirname(macOsDir) // …/Contents const appBundle = p.dirname(contents) // …/Hermes.app - if (appBundle.endsWith('.app')) return appBundle + + if (appBundle.endsWith('.app')) { + return appBundle + } + return null } if (platform === 'win32') { // NSIS per-user installs Hermes.exe directly in the install dir. const dir = p.dirname(exe) - if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir + + if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) { + return dir + } + return null } // Linux: an AppImage exposes its own path via the APPIMAGE env var. - if (env.APPIMAGE) return env.APPIMAGE + if (env.APPIMAGE) { + return env.APPIMAGE + } + // Unpacked electron-builder tree: …/linux-unpacked/hermes const dir = p.dirname(exe) - if (/-unpacked$/.test(dir)) return dir + + if (/-unpacked$/.test(dir)) { + return dir + } + return null } @@ -121,6 +140,7 @@ function shouldRemoveAppBundle(isPackaged, appPath) { */ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) { const q = s => `'${String(s).replace(/'/g, `'\\''`)}'` + const lines = [ '#!/bin/bash', 'set -u', @@ -135,16 +155,21 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, 'fi', `export HERMES_HOME=${q(hermesHome)}` ] + if (pythonPath) { lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`) } + lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`) + if (appPath) { lines.push(`rm -rf ${q(appPath)} || true`) } + // Self-delete the script. lines.push('rm -f "$0" 2>/dev/null || true') lines.push('') + return lines.join('\n') } @@ -180,15 +205,18 @@ function buildWindowsCleanupScript({ // under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be // a problem, but Hermes install paths don't use them. const q = s => `"${String(s).replace(/"/g, '')}"` + const lines = [ '@echo off', 'setlocal enableextensions', `set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`, `set "PID=${pid}"` ] + if (pythonPath) { lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`) } + lines.push( 'set /a waited=0', ':waitloop', @@ -206,6 +234,7 @@ function buildWindowsCleanupScript({ `cd /d ${q(agentRoot)}`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}` ) + if (appPath) { lines.push( 'set /a tries=0', @@ -220,18 +249,20 @@ function buildWindowsCleanupScript({ ':rmdone' ) } + lines.push('del "%~f0"') lines.push('') + return lines.join('\r\n') } -module.exports = { - UNINSTALL_MODES, +export { buildPosixCleanupScript, buildWindowsCleanupScript, modeRemovesAgent, modeRemovesUserData, resolveRemovableAppPath, shouldRemoveAppBundle, + UNINSTALL_MODES, uninstallArgsForMode } diff --git a/apps/desktop/electron/embed-referer.cjs b/apps/desktop/electron/embed-referer.ts similarity index 92% rename from apps/desktop/electron/embed-referer.cjs rename to apps/desktop/electron/embed-referer.ts index 2825eda40e7..834fca0cfb2 100644 --- a/apps/desktop/electron/embed-referer.cjs +++ b/apps/desktop/electron/embed-referer.ts @@ -1,9 +1,8 @@ -'use strict' - -const { session } = require('electron') +import { session } from 'electron' const EMBED_SESSION_PARTITION = 'persist:hermes-embed' const EMBED_REFERER = 'https://www.youtube.com/' + const YOUTUBE_REFERER_HOST_RE = /(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i @@ -23,6 +22,7 @@ function installEmbedRefererForSession(embedSession) { if (!YOUTUBE_REFERER_HOST_RE.test(host)) { callback({ requestHeaders: details.requestHeaders }) + return } @@ -45,4 +45,4 @@ function installEmbedReferer() { } } -module.exports = { installEmbedReferer } +export { installEmbedReferer } diff --git a/apps/desktop/electron/fs-read-dir.test.cjs b/apps/desktop/electron/fs-read-dir.test.ts similarity index 97% rename from apps/desktop/electron/fs-read-dir.test.cjs rename to apps/desktop/electron/fs-read-dir.test.ts index 558ec95b539..c77e60434ec 100644 --- a/apps/desktop/electron/fs-read-dir.test.cjs +++ b/apps/desktop/electron/fs-read-dir.test.ts @@ -1,19 +1,18 @@ -'use strict' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') -const { pathToFileURL } = require('node:url') +import { test } from 'vitest' -const { readDirForIpc } = require('./fs-read-dir.cjs') +import { readDirForIpc } from './fs-read-dir' function mkTmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-')) } -function fakeDirent(name, flags = {}) { +function fakeDirent(name, flags: any = {}) { return { name, isDirectory: () => Boolean(flags.directory), @@ -109,10 +108,12 @@ test('readDirForIpc accepts file URLs for directories', async () => { test('readDirForIpc returns invalid-path for blank or non-string input', async () => { let readdirCalls = 0 + const fsImpl = { promises: { readdir: async () => { readdirCalls += 1 + return [] } } @@ -126,10 +127,12 @@ test('readDirForIpc returns invalid-path for blank or non-string input', async ( test('readDirForIpc rejects Windows device paths before readdir', async () => { let readdirCalls = 0 + const fsImpl = { promises: { readdir: async () => { readdirCalls += 1 + return [] } } @@ -224,6 +227,7 @@ test('readDirForIpc allows expanding symlink or junction directories outside the fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok') const linkPath = path.join(root, 'outside-link') + try { fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir') } catch (error) { @@ -252,6 +256,7 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th const input = path.join('virtual-root') const resolved = path.resolve(input) const statCalls = [] + const fsImpl = { promises: { readdir: async () => [ @@ -266,9 +271,11 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th } statCalls.push(fullPath) + if (fullPath.endsWith(`${path.sep}linked-dir`)) { return { isDirectory: () => true } } + throw Object.assign(new Error('gone'), { code: 'ENOENT' }) } } @@ -301,12 +308,15 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out let peak = 0 let releaseStats let markFirstStatStarted + const statsReleased = new Promise(resolve => { releaseStats = resolve }) + const firstStatStarted = new Promise(resolve => { markFirstStatStarted = resolve }) + const fsImpl = { promises: { readdir: async () => [ @@ -326,6 +336,7 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out active -= 1 const name = path.basename(fullPath) + if (name === failedName) { throw Object.assign(new Error('gone'), { code: 'ENOENT' }) } diff --git a/apps/desktop/electron/fs-read-dir.cjs b/apps/desktop/electron/fs-read-dir.ts similarity index 79% rename from apps/desktop/electron/fs-read-dir.cjs rename to apps/desktop/electron/fs-read-dir.ts index 1a2a00313b5..fe8f58b0bf2 100644 --- a/apps/desktop/electron/fs-read-dir.cjs +++ b/apps/desktop/electron/fs-read-dir.ts @@ -1,8 +1,8 @@ -'use strict' +import fs from 'node:fs' +import path from 'node:path' -const fs = require('node:fs') -const path = require('node:path') -const { resolveDirectoryForIpc } = require('./hardening.cjs') +import { resolveDirectoryForIpc } from './hardening' +import { resolveLocalReadPath } from './wsl-path-bridge' const FS_READDIR_STAT_CONCURRENCY = 16 @@ -37,7 +37,9 @@ function direntIsSymbolicLink(dirent) { } function shouldStatDirent(dirent) { - if (direntIsDirectory(dirent)) return false + if (direntIsDirectory(dirent)) { + return false + } return direntIsSymbolicLink(dirent) || !direntIsFile(dirent) } @@ -70,18 +72,22 @@ async function mapWithStatConcurrency(items, mapper) { } const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length) - const workers = Array.from({ length: workerCount }, () => runWorker()) + const workers = Array.from({ length: workerCount } as any, () => runWorker()) await Promise.all(workers) return results } -async function readDirForIpc(dirPath, options = {}) { +async function readDirForIpc(dirPath, options: any = {}) { const fsImpl = options.fs || fs let resolved + // On a Windows host with a WSL backend, a WSL/POSIX cwd (`/home/...`, + // `/mnt/c/...`) isn't readable as-is; bridge it to a UNC/drive form first. + const readPath = resolveLocalReadPath(String(dirPath ?? '')) + try { - ;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, { + ;({ resolvedPath: resolved } = await resolveDirectoryForIpc(readPath, { fs: fsImpl, purpose: 'Directory read' })) @@ -102,6 +108,4 @@ async function readDirForIpc(dirPath, options = {}) { } } -module.exports = { - readDirForIpc -} +export { readDirForIpc } diff --git a/apps/desktop/electron/gateway-ws-probe.test.cjs b/apps/desktop/electron/gateway-ws-probe.test.ts similarity index 89% rename from apps/desktop/electron/gateway-ws-probe.test.cjs rename to apps/desktop/electron/gateway-ws-probe.test.ts index 810494fdc47..222d3c699c1 100644 --- a/apps/desktop/electron/gateway-ws-probe.test.cjs +++ b/apps/desktop/electron/gateway-ws-probe.test.ts @@ -1,7 +1,7 @@ /** - * Tests for electron/gateway-ws-probe.cjs. + * Tests for electron/gateway-ws-probe.ts. * - * Run with: node --test electron/gateway-ws-probe.test.cjs + * Run with: node --test electron/gateway-ws-probe.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * The probe drives a real WebSocket handshake for the "Test remote" button. @@ -9,16 +9,21 @@ * outcome (open, frame, error, early close, never-opens) without a network. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' -const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') +import { test } from 'vitest' + +import { probeGatewayWebSocket } from './gateway-ws-probe' // Minimal WebSocket double: records listeners synchronously (the probe attaches // them in its executor) and exposes emit() so the test can replay events. -function makeFakeWs() { +function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } { const instances = [] + class FakeWs { + url: string + closed = false + listeners: Record = {} constructor(url) { this.url = url this.listeners = {} @@ -32,9 +37,12 @@ function makeFakeWs() { this.closed = true } emit(type, event) { - for (const fn of this.listeners[type] || []) fn(event) + for (const fn of this.listeners[type] || []) { + fn(event) + } } } + return { FakeWs, instances } } @@ -51,11 +59,13 @@ test('probe resolves ok when the socket opens and stays open', async () => { test('probe resolves ok immediately when a frame arrives', async () => { const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, connectTimeoutMs: 1_000, readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer }) + instances[0].emit('open') instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' }) const result = await promise @@ -95,11 +105,13 @@ test('probe fails when the gateway accepts then immediately closes (auth rejecte test('probe times out when the socket never opens', async () => { const { FakeWs } = makeFakeWs() + const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, connectTimeoutMs: 20, readyGraceMs: 10 }) + assert.equal(result.ok, false) assert.match(result.reason, /Timed out/) }) diff --git a/apps/desktop/electron/gateway-ws-probe.cjs b/apps/desktop/electron/gateway-ws-probe.ts similarity index 86% rename from apps/desktop/electron/gateway-ws-probe.cjs rename to apps/desktop/electron/gateway-ws-probe.ts index 6ed1280be98..152e20b4d32 100644 --- a/apps/desktop/electron/gateway-ws-probe.cjs +++ b/apps/desktop/electron/gateway-ws-probe.ts @@ -36,13 +36,16 @@ const DEFAULT_READY_GRACE_MS = 750 * Attempt a live WebSocket connection and classify the outcome. * * @param {string} wsUrl - Fully-formed ws(s):// URL including the credential. - * @param {object} [options] - * @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor. - * @param {number} [options.connectTimeoutMs] - * @param {number} [options.readyGraceMs] * @returns {Promise<{ ok: boolean, reason?: string }>} */ -function probeGatewayWebSocket(wsUrl, options = {}) { +function probeGatewayWebSocket( + wsUrl: string, + options: { + WebSocketImpl?: any + connectTimeoutMs?: number + readyGraceMs?: number + } = {} +) { const WebSocketImpl = options.WebSocketImpl const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS @@ -54,7 +57,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) { }) } - return new Promise(resolve => { + return new Promise(resolve => { let settled = false let opened = false let connectTimer = null @@ -66,6 +69,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) { clearTimeout(connectTimer) connectTimer = null } + if (graceTimer !== null) { clearTimeout(graceTimer) graceTimer = null @@ -73,14 +77,19 @@ function probeGatewayWebSocket(wsUrl, options = {}) { } const finish = result => { - if (settled) return + if (settled) { + return + } + settled = true clearTimers() + try { socket?.close?.() } catch { // ignore — best effort teardown } + resolve(result) } @@ -91,11 +100,15 @@ function probeGatewayWebSocket(wsUrl, options = {}) { ok: false, reason: error instanceof Error ? error.message : String(error) }) + return } const onOpen = () => { - if (settled) return + if (settled) { + return + } + opened = true // Upgrade accepted. Give the server a brief window to reject the // credential post-handshake (early close) before declaring success. @@ -118,7 +131,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) { } const onClose = event => { - if (settled) return + if (settled) { + return + } + if (opened) { // Opened, then closed inside the grace window: the upgrade was accepted // but the session was refused (e.g. ws-ticket/token rejected, or a @@ -127,8 +143,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) { ok: false, reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).') }) + return } + finish({ ok: false, reason: closeReason(event, 'The gateway closed the WebSocket before it opened.') @@ -154,8 +172,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) { function addListener(socket, type, handler) { if (typeof socket.addEventListener === 'function') { socket.addEventListener(type, handler) + return } + // Node's global WebSocket implements addEventListener; this fallback keeps the // helper usable with the `ws` package's EventEmitter shape too. if (typeof socket.on === 'function') { @@ -164,25 +184,44 @@ function addListener(socket, type, handler) { } function extractErrorReason(event) { - if (!event) return '' - if (event instanceof Error) return event.message + if (!event) { + return '' + } + + if (event instanceof Error) { + return event.message + } + const err = event.error || event.message - if (err instanceof Error) return err.message - if (typeof err === 'string') return err + + if (err instanceof Error) { + return err.message + } + + if (typeof err === 'string') { + return err + } + return '' } function closeReason(event, fallback) { const code = event && typeof event.code === 'number' ? event.code : null const reason = event && typeof event.reason === 'string' ? event.reason.trim() : '' - if (code && reason) return `${fallback} (code ${code}: ${reason})` - if (code) return `${fallback} (code ${code})` - if (reason) return `${fallback} (${reason})` + + if (code && reason) { + return `${fallback} (code ${code}: ${reason})` + } + + if (code) { + return `${fallback} (code ${code})` + } + + if (reason) { + return `${fallback} (${reason})` + } + return fallback } -module.exports = { - DEFAULT_CONNECT_TIMEOUT_MS, - DEFAULT_READY_GRACE_MS, - probeGatewayWebSocket -} +export { DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READY_GRACE_MS, probeGatewayWebSocket } diff --git a/apps/desktop/electron/git-repo-scan.cjs b/apps/desktop/electron/git-repo-scan.ts similarity index 93% rename from apps/desktop/electron/git-repo-scan.cjs rename to apps/desktop/electron/git-repo-scan.ts index f7617b76b70..36ac189e66e 100644 --- a/apps/desktop/electron/git-repo-scan.cjs +++ b/apps/desktop/electron/git-repo-scan.ts @@ -1,14 +1,12 @@ -'use strict' - // Repo-first discovery: walk bounded roots for git repos using only Node's `fs` // — no native addon, so it just works for anyone who pulls main (no // electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git` // (don't descend into a repo), cap depth, and skip heavy non-repo trees so the // first scan stays fast. Results are cached by the backend after the first run. -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' const fsp = fs.promises @@ -36,14 +34,14 @@ async function mapLimit(items, limit, fn) { } } - await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker)) } /** * Scan `roots` (default: the home dir) for git repositories. Returns deduped * `{ root, label }` entries. `options.maxDepth` caps recursion (default 3). */ -async function scanGitRepos(roots, options = {}) { +async function scanGitRepos(roots, options: any = {}) { const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()] const found = new Map() @@ -54,6 +52,7 @@ async function scanGitRepos(roots, options = {}) { } let entries + try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { @@ -73,6 +72,7 @@ async function scanGitRepos(roots, options = {}) { } const subdirs = [] + for (const entry of entries) { // Real directories only (skip symlinks to avoid loops), no hidden dirs, no // known heavy trees. @@ -93,4 +93,4 @@ async function scanGitRepos(roots, options = {}) { return [...found.entries()].map(([root, label]) => ({ label, root })) } -module.exports = { scanGitRepos } +export { scanGitRepos } diff --git a/apps/desktop/electron/git-review-ops.test.cjs b/apps/desktop/electron/git-review-ops.test.ts similarity index 78% rename from apps/desktop/electron/git-review-ops.test.cjs rename to apps/desktop/electron/git-review-ops.test.ts index fdddd13df78..a06fcdace39 100644 --- a/apps/desktop/electron/git-review-ops.test.cjs +++ b/apps/desktop/electron/git-review-ops.test.ts @@ -1,9 +1,8 @@ -'use strict' +import assert from 'node:assert/strict' -const assert = require('node:assert/strict') -const test = require('node:test') +import { test } from 'vitest' -const { resolveRenamePath } = require('./git-review-ops.cjs') +import { resolveRenamePath } from './git-review-ops' test('resolveRenamePath: plain path is unchanged', () => { assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts') diff --git a/apps/desktop/electron/git-review-ops.cjs b/apps/desktop/electron/git-review-ops.ts similarity index 93% rename from apps/desktop/electron/git-review-ops.cjs rename to apps/desktop/electron/git-review-ops.ts index a7df19880eb..1baf26d2fca 100644 --- a/apps/desktop/electron/git-review-ops.cjs +++ b/apps/desktop/electron/git-review-ops.ts @@ -1,37 +1,16 @@ -'use strict' - // Git ops backing the coding rail + Codex-style review pane. Built on `simple-git` // (a maintained wrapper around the system git binary — same git the rest of the // app shells to, no native build) so we read structured status()/diffSummary() // results instead of hand-parsing porcelain. Reads degrade to null/empty on a // non-repo / remote backend; mutations reject so the renderer can toast. -const { execFile } = require('node:child_process') -const fs = require('node:fs/promises') -const path = require('node:path') +import { execFile } from 'node:child_process' +import fs from 'node:fs/promises' +import path from 'node:path' -// `simple-git` is a pure-JS runtime dep that workspace dedup hoists into the -// repo-root node_modules. Packaged builds set `files:` in package.json, which -// excludes node_modules from the asar, so the normal require() fails at launch -// (issue #52735: "Cannot find module 'simple-git'"). We ship the dep's -// closure under resources/native-deps/vendor/node_modules/ via extraResources -// + scripts/stage-native-deps.cjs, and resolve from there when the hoisted -// require() isn't reachable. The `vendor/` nesting matters: electron-builder -// drops a node_modules dir at the root of an extraResources copy but keeps a -// nested one. Dev mode never hits the fallback -- Node's normal lookup finds -// the hoisted copy. -let simpleGit -try { - simpleGit = require('simple-git') -} catch { - const resourcesPath = process.resourcesPath - if (!resourcesPath) { - throw new Error("git-review IPC: 'simple-git' not found and no resourcesPath to fall back to") - } - simpleGit = require(path.join(resourcesPath, 'native-deps', 'vendor', 'node_modules', 'simple-git')) -} +import simpleGit from 'simple-git' -const { resolveRequestedPathForIpc } = require('./hardening.cjs') +import { resolveRequestedPathForIpc } from './hardening' const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000 const COMMIT_CONTEXT_UNTRACKED_MAX = 80 @@ -52,7 +31,7 @@ function ghEnv(ghBin) { // Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on // availability/auth without a throw. gh missing/unauthed → ok:false. -function runGh(args, cwd, ghBin) { +function runGh(args, cwd, ghBin): Promise<{ ok: boolean; stdout: string }> { return new Promise(resolve => { execFile( ghBin || 'gh', @@ -260,10 +239,11 @@ async function reviewList(repoPath, scope, baseRef, gitBin) { const range = scope === 'branch' ? `${base}...HEAD` : base const summary = await git.diffSummary([range]) + const files = summary.files.map(file => ({ path: resolveRenamePath(file.file), - added: file.binary ? 0 : file.insertions, - removed: file.binary ? 0 : file.deletions, + added: 'insertions' in file ? file.insertions : 0, + removed: 'deletions' in file ? file.deletions : 0, status: 'M', staged: false })) @@ -291,6 +271,7 @@ async function reviewList(repoPath, scope, baseRef, gitBin) { git.diffSummary(['--cached']), git.diffSummary([]) ]) + const stagedCounts = countsByPath(staged) const unstagedCounts = countsByPath(unstaged) @@ -495,6 +476,7 @@ async function reviewCommitContext(repoPath, gitBin) { const safe = args => git.diff(args).catch(() => '') let status + try { status = await git.status() } catch { @@ -510,9 +492,11 @@ async function reviewCommitContext(repoPath, gitBin) { // Untracked files have no diff — list them so new files aren't invisible. const untracked = status.not_added || [] + if (untracked.length > 0) { const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX) const omitted = untracked.length - visible.length + const note = `\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` + (omitted > 0 ? `# ... ${omitted} more omitted\n` : '') @@ -607,6 +591,7 @@ async function repoStatus(repoPath, gitBin) { // fail soft and hide the coding rail instead of spamming IPC handler errors. try { const stat = await fs.stat(cwd) + if (!stat.isDirectory()) { return null } @@ -615,11 +600,13 @@ async function repoStatus(repoPath, gitBin) { } let git + try { git = gitFor(cwd, gitBin) } catch { return null } + let status try { @@ -630,6 +617,7 @@ async function repoStatus(repoPath, gitBin) { } const detached = typeof status.detached === 'boolean' ? status.detached : !status.current + const files = status.files.map(file => ({ path: file.path, staged: isStaged(file), @@ -671,10 +659,12 @@ async function repoStatus(repoPath, gitBin) { // can't stall the probe. try { const untracked = status.not_added.slice(0, 500) + for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { const batch = await Promise.all( untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path)) ) + result.added += batch.reduce((sum, n) => sum + n, 0) } } catch { @@ -684,7 +674,7 @@ async function repoStatus(repoPath, gitBin) { return result } -module.exports = { +export { branchBase, fileDiffVsHead, repoStatus, @@ -695,8 +685,8 @@ module.exports = { reviewDiff, reviewList, reviewPush, - reviewRevParse, reviewRevert, + reviewRevParse, reviewShipInfo, reviewStage, reviewUnstage diff --git a/apps/desktop/electron/git-root.test.cjs b/apps/desktop/electron/git-root.test.cjs deleted file mode 100644 index ba649b259f3..00000000000 --- a/apps/desktop/electron/git-root.test.cjs +++ /dev/null @@ -1,40 +0,0 @@ -'use strict' - -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') -const { pathToFileURL } = require('node:url') - -const { gitRootForIpc } = require('./git-root.cjs') - -function mkTmpDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-')) -} - -test('gitRootForIpc returns null for invalid and device paths', async () => { - assert.equal(await gitRootForIpc(''), null) - assert.equal(await gitRootForIpc(' '), null) - assert.equal(await gitRootForIpc(null), null) - assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null) - assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null) -}) - -test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => { - const root = mkTmpDir() - t.after(() => fs.rmSync(root, { recursive: true, force: true })) - - const gitDir = path.join(root, '.git') - const srcDir = path.join(root, 'src') - const filePath = path.join(srcDir, 'index.ts') - fs.mkdirSync(gitDir) - fs.mkdirSync(srcDir) - fs.writeFileSync(filePath, 'export {}\n', 'utf8') - - assert.equal(await gitRootForIpc(root), root) - assert.equal(await gitRootForIpc(srcDir), root) - assert.equal(await gitRootForIpc(filePath), root) - assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root) - assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root) -}) diff --git a/apps/desktop/electron/git-root.test.ts b/apps/desktop/electron/git-root.test.ts new file mode 100644 index 00000000000..e4a1758c3d0 --- /dev/null +++ b/apps/desktop/electron/git-root.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { test } from 'vitest' + +import { gitRootForIpc } from './git-root' + +function mkTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-')) +} + +test('gitRootForIpc returns null for invalid and device paths', async () => { + assert.equal(await gitRootForIpc(''), null) + assert.equal(await gitRootForIpc(' '), null) + assert.equal(await gitRootForIpc(null), null) + assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null) + assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null) +}) + +test('gitRootForIpc resolves directories files missing descendants and file URLs', async () => { + const root = mkTmpDir() + + try { + const gitDir = path.join(root, '.git') + const srcDir = path.join(root, 'src') + const filePath = path.join(srcDir, 'index.ts') + fs.mkdirSync(gitDir) + fs.mkdirSync(srcDir) + fs.writeFileSync(filePath, 'export {}\n', 'utf8') + + assert.equal(await gitRootForIpc(root), root) + assert.equal(await gitRootForIpc(srcDir), root) + assert.equal(await gitRootForIpc(filePath), root) + assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root) + assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/git-root.cjs b/apps/desktop/electron/git-root.ts similarity index 75% rename from apps/desktop/electron/git-root.cjs rename to apps/desktop/electron/git-root.ts index 593d3531ebc..bd19c25a8fd 100644 --- a/apps/desktop/electron/git-root.cjs +++ b/apps/desktop/electron/git-root.ts @@ -1,8 +1,7 @@ -'use strict' +import fs from 'node:fs' +import path from 'node:path' -const fs = require('node:fs') -const path = require('node:path') -const { resolveRequestedPathForIpc } = require('./hardening.cjs') +import { resolveRequestedPathForIpc } from './hardening' function findGitRoot(start, fsImpl = fs) { let dir = start @@ -28,7 +27,7 @@ function findGitRoot(start, fsImpl = fs) { return null } -async function gitRootForIpc(startPath, options = {}) { +async function gitRootForIpc(startPath, options: { fs?: typeof fs } = {}) { const fsImpl = options.fs || fs let resolved @@ -48,7 +47,4 @@ async function gitRootForIpc(startPath, options = {}) { } } -module.exports = { - findGitRoot, - gitRootForIpc -} +export { findGitRoot, gitRootForIpc } diff --git a/apps/desktop/electron/git-worktree-ops.test.cjs b/apps/desktop/electron/git-worktree-ops.test.ts similarity index 77% rename from apps/desktop/electron/git-worktree-ops.test.cjs rename to apps/desktop/electron/git-worktree-ops.test.ts index b0865d4ad77..a543ecd39f2 100644 --- a/apps/desktop/electron/git-worktree-ops.test.cjs +++ b/apps/desktop/electron/git-worktree-ops.test.ts @@ -1,20 +1,20 @@ -'use strict' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' -const assert = require('node:assert/strict') -const { execFileSync } = require('node:child_process') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') +import { test } from 'vitest' -const { +import { addWorktree, ensureGitRepo, + listBaseBranches, listBranches, parseWorktrees, sanitizeBranch, switchBranch -} = require('./git-worktree-ops.cjs') +} from './git-worktree-ops' test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => { assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes') @@ -212,3 +212,53 @@ test('addWorktree: existing default branch switches the main checkout, not .work fs.rmSync(dir, { recursive: true, force: true }) } }) + +test('listBaseBranches: lists local branches and flags the default', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-branches-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + const trunk = git('branch', '--show-current') + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + + const branches = await listBaseBranches(dir, 'git') + const names = branches.map(b => b.name).sort() + + assert.deepEqual(names, [trunk, 'feature'].sort()) + // No remote → all local. + assert.equal(branches.every(b => !b.isRemote), true) + // The trunk is flagged as the default. + assert.equal(branches.find(b => b.name === trunk).isDefault, true) + assert.equal(branches.find(b => b.name === 'feature').isDefault, false) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBaseBranches: empty on a non-repo path', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-nonrepo-')) + + try { + assert.deepEqual(await listBaseBranches(dir, 'git'), []) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('addWorktree: base param branches off a specified local branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-add-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'staging'], { cwd: dir }) + + const result = await addWorktree(dir, { base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' }, 'git') + + assert.equal(result.branch, 'new-from-staging') + assert.equal(git('-C', result.path, 'merge-base', 'HEAD', 'staging').length > 0, true) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/git-worktree-ops.cjs b/apps/desktop/electron/git-worktree-ops.ts similarity index 78% rename from apps/desktop/electron/git-worktree-ops.cjs rename to apps/desktop/electron/git-worktree-ops.ts index de4e01cfb94..e84324a004b 100644 --- a/apps/desktop/electron/git-worktree-ops.cjs +++ b/apps/desktop/electron/git-worktree-ops.ts @@ -1,16 +1,14 @@ -'use strict' - // Git-driven worktree operations for the desktop "Start work" flow: spin up a // fresh worktree the lightest way (`git worktree add -b`), list real worktrees, // and remove them. Git is the source of truth; the renderer just drives these. -const path = require('node:path') -const fs = require('node:fs') -const { execFile } = require('node:child_process') +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' -const { resolveRequestedPathForIpc } = require('./hardening.cjs') +import { resolveRequestedPathForIpc } from './hardening' -function runGit(gitBin, args, cwd) { +function runGit(gitBin, args, cwd): Promise { return new Promise((resolve, reject) => { execFile( gitBin, @@ -253,7 +251,25 @@ async function addWorktree(repoPath, options, gitBin) { const args = ['worktree', 'add', '-b', branch, dir] if (opts.base) { - args.push(String(opts.base)) + // Remote-tracking branches may be stale or missing if the user hasn't + // fetched recently. When the base is an `origin/…` ref, fetch just that + // branch so `git worktree add -b new origin/main` works against the + // latest remote commit. Local branches are used as-is. + const base = String(opts.base) + + if (base.startsWith('origin/')) { + const remoteBranch = base.slice('origin/'.length) + + try { + await runGit(gitBin, ['fetch', 'origin', remoteBranch], root) + } catch { + // The fetch isn't mandatory, but it would be nice to do if possible. + // If it's not possible, just use the local ref of the remote branch. + // If it doesn't exist locally, we'll get an error anyways + } + } + + args.push(base) } try { @@ -306,6 +322,7 @@ async function listBranches(repoPath, gitBin) { ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'], resolved ) + const trees = await listWorktrees(resolved, gitBin) const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path])) const trunk = await defaultBranch(gitBin, resolved) @@ -338,9 +355,56 @@ async function switchBranch(repoPath, branch, gitBin) { return { branch: target } } -module.exports = { +// Branches the new worktree can be based on: local heads + remote-tracking +// refs. Listed most-recently-committed first; the remote's default branch +// (origin/HEAD) is flagged so the UI can preselect it. Empty on a non-repo / +// remote backend where the probe can't run. +async function listBaseBranches(repoPath, gitBin) { + let resolved + + try { + resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Base branch list' }) + } catch { + return [] + } + + try { + const out = await runGit( + gitBin, + ['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso)', '--sort=-committerdate', 'refs/heads', 'refs/remotes'], + resolved + ) + + const remoteDefault = await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], resolved) + const localDefault = await defaultBranch(gitBin, resolved) + + return out + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + const [name] = line.split('\t') + + return { + name, + isRemote: name.startsWith('origin/'), + // origin/HEAD when a remote exists; otherwise the local default + // (main/master/init.defaultBranch) so a no-remote repo still flags + // its trunk. + isDefault: Boolean( + (remoteDefault && name === remoteDefault) || (!remoteDefault && localDefault && name === localDefault) + ) + } + }) + } catch { + return [] + } +} + +export { addWorktree, ensureGitRepo, + listBaseBranches, listBranches, listWorktrees, parseWorktrees, diff --git a/apps/desktop/electron/hardening.test.cjs b/apps/desktop/electron/hardening.test.cjs deleted file mode 100644 index b38a03b0082..00000000000 --- a/apps/desktop/electron/hardening.test.cjs +++ /dev/null @@ -1,279 +0,0 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') -const { pathToFileURL } = require('node:url') - -const { - DEFAULT_FETCH_TIMEOUT_MS, - encryptDesktopSecret, - resolveDirectoryForIpc, - resolveReadableFileForIpc, - resolveRequestedPathForIpc, - resolveTimeoutMs, - sensitiveFileBlockReason -} = require('./hardening.cjs') - -async function rejectsWithCode(promise, code) { - await assert.rejects(promise, error => { - assert.equal(error?.code, code) - return true - }) -} - -test('resolveTimeoutMs falls back to defaults and accepts overrides', () => { - assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS) - assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS) - assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS) - assert.equal(resolveTimeoutMs('2750'), 2750) -}) - -test('encryptDesktopSecret requires available secure storage', () => { - assert.equal( - encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }), - null - ) - - assert.throws( - () => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }), - /Secure token storage is unavailable/ - ) -}) - -test('encryptDesktopSecret stores safeStorage base64 payload', () => { - const secret = encryptDesktopSecret('token-123', { - isEncryptionAvailable: () => true, - encryptString: value => Buffer.from(`enc:${value}`, 'utf8') - }) - - assert.deepEqual(secret, { - encoding: 'safeStorage', - value: Buffer.from('enc:token-123', 'utf8').toString('base64') - }) -}) - -test('sensitiveFileBlockReason blocks obvious secret file patterns', () => { - assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/) - assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null) - assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/) - assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/) -}) - -test('path helpers reject blank non-string NUL and Windows device syntax', async () => { - await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path') - await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path') - await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path') - await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path') - - const devicePaths = [ - '\\\\?\\C:\\secret.txt', - '\\\\.\\C:\\secret.txt', - '\\\\?\\UNC\\server\\share\\secret.txt', - 'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt' - ] - - for (const devicePath of devicePaths) { - assert.throws( - () => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }), - error => { - assert.equal(error?.code, 'device-path') - return true - } - ) - await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path') - } - - assert.throws( - () => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), - error => { - assert.equal(error?.code, 'invalid-path') - return true - } - ) - await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path') -}) - -test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => { - const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base') - - assert.equal( - resolveRequestedPathForIpc('notes.txt', { - baseDir: ` ${baseDir} `, - purpose: 'File preview' - }), - path.resolve(baseDir, 'notes.txt') - ) -}) - -test('resolveRequestedPathForIpc expands ~ to the home directory', () => { - assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir())) - assert.equal( - resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }), - path.resolve(os.homedir(), 'www/project') - ) - // `~user` shorthand is NOT expanded — only the caller's own home. - assert.equal( - resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }), - path.resolve(os.tmpdir(), '~other/secret') - ) -}) - -test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-')) - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) - - const textPath = path.join(tempDir, 'notes.txt') - fs.writeFileSync(textPath, 'hello world', 'utf8') - - const fromRelative = await resolveReadableFileForIpc('notes.txt', { - baseDir: tempDir, - maxBytes: 256, - purpose: 'File preview' - }) - assert.equal(fromRelative.resolvedPath, textPath) - assert.equal(fromRelative.stat.size, 11) - - const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), { - purpose: 'File preview' - }) - assert.equal(fromFileUrl.resolvedPath, textPath) - - const spacedPath = path.join(tempDir, 'notes with spaces.txt') - fs.writeFileSync(spacedPath, 'space ok', 'utf8') - const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), { - purpose: 'File preview' - }) - assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath) - - await assert.rejects( - resolveReadableFileForIpc('missing.txt', { - baseDir: tempDir, - purpose: 'Text preview' - }), - /file does not exist/ - ) - - const nestedDir = path.join(tempDir, 'directory') - fs.mkdirSync(nestedDir) - await assert.rejects( - resolveReadableFileForIpc(nestedDir, { - purpose: 'Text preview' - }), - /path points to a directory/ - ) - - const largePath = path.join(tempDir, 'large.txt') - fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8') - await assert.rejects( - resolveReadableFileForIpc(largePath, { - maxBytes: 8, - purpose: 'File preview' - }), - /file is too large/ - ) - - const envPath = path.join(tempDir, '.env') - fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') - await assert.rejects( - resolveReadableFileForIpc(envPath, { - purpose: 'File preview' - }), - /blocked for sensitive file/ - ) - - const envTemplatePath = path.join(tempDir, '.env.example') - fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8') - const envTemplate = await resolveReadableFileForIpc(envTemplatePath, { - purpose: 'File preview' - }) - assert.equal(envTemplate.resolvedPath, envTemplatePath) -}) - -test('resolveReadableFileForIpc blocks common sensitive files', async t => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-')) - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) - - const sshDir = path.join(tempDir, '.ssh') - fs.mkdirSync(sshDir) - - const blockedFiles = [ - path.join(tempDir, '.env'), - path.join(tempDir, '.npmrc'), - path.join(sshDir, 'id_ed25519'), - path.join(tempDir, 'cert.pem'), - path.join(tempDir, 'cert.p12'), - path.join(tempDir, 'cert.pfx') - ] - - for (const filePath of blockedFiles) { - fs.writeFileSync(filePath, 'secret', 'utf8') - await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file') - } - - const allowed = path.join(tempDir, '.env.example') - fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8') - assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed) -}) - -test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-')) - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) - - const envPath = path.join(tempDir, '.env') - const linkPath = path.join(tempDir, 'safe-name.txt') - fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') - - try { - fs.symlinkSync(envPath, linkPath, 'file') - } catch (error) { - if (error?.code === 'EPERM' || error?.code === 'EACCES') { - t.skip(`symlink creation is not permitted on this platform (${error.code})`) - return - } - throw error - } - - await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file') -}) - -test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-')) - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) - - const directory = path.join(tempDir, 'project') - const filePath = path.join(tempDir, 'file.txt') - fs.mkdirSync(directory) - fs.writeFileSync(filePath, 'not a directory', 'utf8') - - const resolved = await resolveDirectoryForIpc(directory) - assert.equal(resolved.resolvedPath, directory) - assert.equal(resolved.stat.isDirectory(), true) - - await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR') - await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT') - await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path') -}) - -test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-')) - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) - - const directory = path.join(tempDir, 'actual-project') - const linkPath = path.join(tempDir, 'linked-project') - fs.mkdirSync(directory) - - try { - fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir') - } catch (error) { - if (error?.code === 'EPERM' || error?.code === 'EACCES') { - t.skip(`directory symlink creation is not permitted on this platform (${error.code})`) - return - } - throw error - } - - const resolved = await resolveDirectoryForIpc(linkPath) - assert.equal(resolved.resolvedPath, linkPath) - assert.equal(resolved.stat.isDirectory(), true) -}) diff --git a/apps/desktop/electron/hardening.test.ts b/apps/desktop/electron/hardening.test.ts new file mode 100644 index 00000000000..1a5852f720a --- /dev/null +++ b/apps/desktop/electron/hardening.test.ts @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { test } from 'vitest' + +import { + DEFAULT_FETCH_TIMEOUT_MS, + encryptDesktopSecret, + resolveDirectoryForIpc, + resolveReadableFileForIpc, + resolveRequestedPathForIpc, + resolveTimeoutMs, + sensitiveFileBlockReason +} from './hardening' + +async function rejectsWithCode(promise, code: string) { + await assert.rejects(promise, (error: any) => { + assert.equal(error?.code, code) + + return true + }) +} + +test('resolveTimeoutMs falls back to defaults and accepts overrides', () => { + assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS) + assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS) + assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS) + assert.equal(resolveTimeoutMs('2750'), 2750) +}) + +test('encryptDesktopSecret requires available secure storage', () => { + assert.equal( + encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }), + null + ) + + assert.throws( + () => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }), + /Secure token storage is unavailable/ + ) +}) + +test('encryptDesktopSecret stores safeStorage base64 payload', () => { + const secret = encryptDesktopSecret('token-123', { + isEncryptionAvailable: () => true, + encryptString: value => Buffer.from(`enc:${value}`, 'utf8') + }) + + assert.deepEqual(secret, { + encoding: 'safeStorage', + value: Buffer.from('enc:token-123', 'utf8').toString('base64') + }) +}) + +test('sensitiveFileBlockReason blocks obvious secret file patterns', () => { + assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/) + assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null) + assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/) + assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/) +}) + +test('path helpers reject blank non-string NUL and Windows device syntax', async () => { + await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path') + + const devicePaths = [ + '\\\\?\\C:\\secret.txt', + '\\\\.\\C:\\secret.txt', + '\\\\?\\UNC\\server\\share\\secret.txt', + 'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt' + ] + + for (const devicePath of devicePaths) { + assert.throws( + () => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }), + (error: any) => { + assert.equal(error?.code, 'device-path') + + return true + } + ) + await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path') + } + + assert.throws( + () => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), + (error: any) => { + assert.equal(error?.code, 'invalid-path') + + return true + } + ) + await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path') +}) + +test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => { + const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base') + + assert.equal( + resolveRequestedPathForIpc('notes.txt', { + baseDir: ` ${baseDir} `, + purpose: 'File preview' + }), + path.resolve(baseDir, 'notes.txt') + ) +}) + +test('resolveRequestedPathForIpc expands ~ to the home directory', () => { + assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir())) + assert.equal( + resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }), + path.resolve(os.homedir(), 'www/project') + ) + // `~user` shorthand is NOT expanded — only the caller's own home. + assert.equal( + resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }), + path.resolve(os.tmpdir(), '~other/secret') + ) +}) + +test('resolveReadableFileForIpc validates existence type size and sensitivity', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-')) + + try { + const textPath = path.join(tempDir, 'notes.txt') + fs.writeFileSync(textPath, 'hello world', 'utf8') + + const fromRelative = await resolveReadableFileForIpc('notes.txt', { + baseDir: tempDir, + maxBytes: 256, + purpose: 'File preview' + }) + + assert.equal(fromRelative.resolvedPath, textPath) + assert.equal(fromRelative.stat.size, 11) + + const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), { + purpose: 'File preview' + }) + + assert.equal(fromFileUrl.resolvedPath, textPath) + + const spacedPath = path.join(tempDir, 'notes with spaces.txt') + fs.writeFileSync(spacedPath, 'space ok', 'utf8') + + const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), { + purpose: 'File preview' + }) + + assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath) + + await assert.rejects( + resolveReadableFileForIpc('missing.txt', { + baseDir: tempDir, + purpose: 'Text preview' + }), + /file does not exist/ + ) + + const nestedDir = path.join(tempDir, 'directory') + fs.mkdirSync(nestedDir) + await assert.rejects( + resolveReadableFileForIpc(nestedDir, { + purpose: 'Text preview' + }), + /path points to a directory/ + ) + + const largePath = path.join(tempDir, 'large.txt') + fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8') + await assert.rejects( + resolveReadableFileForIpc(largePath, { + maxBytes: 8, + purpose: 'File preview' + }), + /file is too large/ + ) + + const envPath = path.join(tempDir, '.env') + fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') + await assert.rejects( + resolveReadableFileForIpc(envPath, { + purpose: 'File preview' + }), + /blocked for sensitive file/ + ) + + const envTemplatePath = path.join(tempDir, '.env.example') + fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8') + + const envTemplate = await resolveReadableFileForIpc(envTemplatePath, { + purpose: 'File preview' + }) + + assert.equal(envTemplate.resolvedPath, envTemplatePath) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveReadableFileForIpc blocks common sensitive files', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-')) + + try { + const sshDir = path.join(tempDir, '.ssh') + fs.mkdirSync(sshDir) + + const blockedFiles = [ + path.join(tempDir, '.env'), + path.join(tempDir, '.npmrc'), + path.join(sshDir, 'id_ed25519'), + path.join(tempDir, 'cert.pem'), + path.join(tempDir, 'cert.p12'), + path.join(tempDir, 'cert.pfx') + ] + + for (const filePath of blockedFiles) { + fs.writeFileSync(filePath, 'secret', 'utf8') + await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file') + } + + const allowed = path.join(tempDir, '.env.example') + fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8') + assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-')) + + try { + const envPath = path.join(tempDir, '.env') + const linkPath = path.join(tempDir, 'safe-name.txt') + fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') + + try { + fs.symlinkSync(envPath, linkPath, 'file') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + // symlink creation is not permitted on this platform — skip + return + } + + throw error + } + + await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file') + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-')) + + try { + const directory = path.join(tempDir, 'project') + const filePath = path.join(tempDir, 'file.txt') + fs.mkdirSync(directory) + fs.writeFileSync(filePath, 'not a directory', 'utf8') + + const resolved = await resolveDirectoryForIpc(directory) + assert.equal(resolved.resolvedPath, directory) + assert.equal(resolved.stat.isDirectory(), true) + + await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR') + await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT') + await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path') + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveDirectoryForIpc accepts directory symlinks or junctions', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-')) + + try { + const directory = path.join(tempDir, 'actual-project') + const linkPath = path.join(tempDir, 'linked-project') + fs.mkdirSync(directory) + + try { + fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + // directory symlink creation is not permitted on this platform — skip + return + } + + throw error + } + + const resolved = await resolveDirectoryForIpc(linkPath) + assert.equal(resolved.resolvedPath, linkPath) + assert.equal(resolved.stat.isDirectory(), true) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/hardening.cjs b/apps/desktop/electron/hardening.ts similarity index 89% rename from apps/desktop/electron/hardening.cjs rename to apps/desktop/electron/hardening.ts index 574e659f96c..2d6b5331001 100644 --- a/apps/desktop/electron/hardening.cjs +++ b/apps/desktop/electron/hardening.ts @@ -1,7 +1,7 @@ -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { fileURLToPath } = require('node:url') +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' const DEFAULT_FETCH_TIMEOUT_MS = 15_000 const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024 @@ -13,6 +13,7 @@ const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx']) function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) { const fallback = Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS + const parsed = Number(timeoutMs) if (Number.isFinite(parsed) && parsed > 0) { @@ -62,6 +63,7 @@ function sensitiveFileBlockReason(filePath) { const normalized = String(filePath || '') .replace(/\\/g, '/') .toLowerCase() + const basename = path.basename(normalized) const ext = path.extname(basename) @@ -87,6 +89,7 @@ function sensitiveFileBlockReason(filePath) { if (basename.startsWith('.env.')) { const suffix = basename.slice('.env.'.length) + if (!SAFE_ENV_SUFFIXES.has(suffix)) { return `${basename} is blocked because it appears to contain environment secrets.` } @@ -107,9 +110,11 @@ function sensitiveFileBlockReason(filePath) { return null } -function ipcPathError(code, message) { - const error = new Error(message) - error.code = code +function ipcPathError(code: any, message: string): Error & { code: any } { + const error = new Error(message) as Error & { code: any } + + ;(error as any).code = code + return error } @@ -129,6 +134,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') { } const normalized = raw.replace(/\\/g, '/').toLowerCase() + if ( normalized.startsWith('//?/') || normalized.startsWith('//./') || @@ -141,7 +147,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') { return raw } -function resolveRequestedPathForIpc(filePath, options = {}) { +function resolveRequestedPathForIpc(filePath, options: { purpose?: string; baseDir?: fs.PathOrFileDescriptor } = {}) { const purpose = String(options.purpose || 'File read') let raw = rejectUnsafePathSyntax(filePath, purpose) @@ -154,17 +160,21 @@ function resolveRequestedPathForIpc(filePath, options = {}) { if (/^file:/i.test(raw)) { let resolvedPath + try { const parsed = new URL(raw) + if (parsed.protocol !== 'file:') { throw new Error('not a file URL') } + resolvedPath = fileURLToPath(parsed) } catch { throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`) } rejectUnsafePathSyntax(resolvedPath, purpose) + return path.resolve(resolvedPath) } @@ -178,14 +188,16 @@ function resolveRequestedPathForIpc(filePath, options = {}) { return resolvedPath } -async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) { +async function statForIpc(fsImpl: { promises: { stat: typeof fs.promises.stat } }, resolvedPath, purpose, typeLabel) { try { return await fsImpl.promises.stat(resolvedPath) } catch (error) { const code = error && typeof error === 'object' ? error.code : '' + if (code === 'ENOENT' || code === 'ENOTDIR') { throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`) } + throw ipcPathError( code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}` @@ -201,6 +213,7 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) { try { const realPath = await fsImpl.promises.realpath(resolvedPath) rejectUnsafePathSyntax(realPath, purpose) + return realPath } catch (error) { const code = error && typeof error === 'object' ? error.code : '' @@ -213,12 +226,20 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) { function rejectSensitiveFilePath(filePath, purpose) { const blockReason = sensitiveFileBlockReason(filePath) + if (blockReason) { throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`) } } -async function resolveDirectoryForIpc(dirPath, options = {}) { +async function resolveDirectoryForIpc( + dirPath, + options: { + purpose?: string + baseDir?: fs.PathOrFileDescriptor + fs?: { promises: { stat: typeof fs.promises.stat } } + } = {} +) { const purpose = String(options.purpose || 'Directory read') const fsImpl = options.fs || fs const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose }) @@ -233,7 +254,16 @@ async function resolveDirectoryForIpc(dirPath, options = {}) { return { realPath, resolvedPath, stat } } -async function resolveReadableFileForIpc(filePath, options = {}) { +async function resolveReadableFileForIpc( + filePath, + options: { + purpose?: string + baseDir?: fs.PathOrFileDescriptor + fs?: typeof fs + blockSensitive?: boolean + maxBytes?: number + } = {} +) { const purpose = String(options.purpose || 'File read') const fsImpl = options.fs || fs const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose }) @@ -253,11 +283,13 @@ async function resolveReadableFileForIpc(filePath, options = {}) { } const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose) + if (options.blockSensitive !== false) { rejectSensitiveFilePath(realPath, purpose) } const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null + if (maxBytes && stat.size > maxBytes) { throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`) } @@ -271,15 +303,15 @@ async function resolveReadableFileForIpc(filePath, options = {}) { return { realPath, resolvedPath, stat } } -module.exports = { +export { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, - TEXT_PREVIEW_SOURCE_MAX_BYTES, encryptDesktopSecret, rejectUnsafePathSyntax, resolveDirectoryForIpc, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, - sensitiveFileBlockReason + sensitiveFileBlockReason, + TEXT_PREVIEW_SOURCE_MAX_BYTES } diff --git a/apps/desktop/electron/link-title-window.test.cjs b/apps/desktop/electron/link-title-window.test.ts similarity index 96% rename from apps/desktop/electron/link-title-window.test.cjs rename to apps/desktop/electron/link-title-window.test.ts index 468c646a047..f81190f47dc 100644 --- a/apps/desktop/electron/link-title-window.test.cjs +++ b/apps/desktop/electron/link-title-window.test.ts @@ -1,15 +1,17 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' -const { +import { test } from 'vitest' + +import { createLinkTitleWindow, guardLinkTitleSession, linkTitleWindowOptions, readLinkTitleWindowTitle -} = require('./link-title-window.cjs') +} from './link-title-window' function makeFakeBrowserWindow() { const calls = { audioMuted: [] } + const FakeBrowserWindow = function (options) { this.options = options this.webContents = { diff --git a/apps/desktop/electron/link-title-window.cjs b/apps/desktop/electron/link-title-window.ts similarity index 80% rename from apps/desktop/electron/link-title-window.cjs rename to apps/desktop/electron/link-title-window.ts index c6792bf989e..0920976a528 100644 --- a/apps/desktop/electron/link-title-window.cjs +++ b/apps/desktop/electron/link-title-window.ts @@ -1,11 +1,9 @@ -'use strict' - // Hidden BrowserWindow used by tier-2 link-title resolution: when curl can't // read a page (bot walls, JS-rendered pages), we briefly load the URL // in an offscreen window and read its title. That window loads arbitrary // user-linked pages, so it must never emit sound or trigger real downloads. -function linkTitleWindowOptions(partitionSession) { +export function linkTitleWindowOptions(partitionSession) { return { show: false, width: 1280, @@ -25,7 +23,7 @@ function linkTitleWindowOptions(partitionSession) { // Create the offscreen title-fetch window and immediately mute it. Without the // mute, autoplaying media on the loaded page (e.g. a YouTube link) leaks ~2s of // audio every time a session containing such links is re-rendered. See #49505. -function createLinkTitleWindow(BrowserWindow, partitionSession) { +export function createLinkTitleWindow(BrowserWindow, partitionSession) { const window = new BrowserWindow(linkTitleWindowOptions(partitionSession)) try { @@ -41,7 +39,7 @@ function createLinkTitleWindow(BrowserWindow, partitionSession) { // Cancel any download the title-fetch window triggers. Without this, a link // artifact URL served with Content-Disposition: attachment auto-downloads every // time the Artifacts page renders and fetchLinkTitle loads it. -function guardLinkTitleSession(partitionSession) { +export function guardLinkTitleSession(partitionSession) { try { partitionSession.on('will-download', (_event, item) => item.cancel()) } catch { @@ -52,20 +50,20 @@ function guardLinkTitleSession(partitionSession) { // Read the page title from a title-fetch window. Callers schedule this from // timers that can fire after finish() destroys the window, so every access must // guard isDestroyed and swallow Electron's "Object has been destroyed" throws. -function readLinkTitleWindowTitle(window) { +export function readLinkTitleWindowTitle(window) { try { - if (!window || window.isDestroyed()) return '' + if (!window || window.isDestroyed()) { + return '' + } + const contents = window.webContents - if (!contents || contents.isDestroyed()) return '' + + if (!contents || contents.isDestroyed()) { + return '' + } + return contents.getTitle() || '' } catch { return '' } } - -module.exports = { - createLinkTitleWindow, - guardLinkTitleSession, - linkTitleWindowOptions, - readLinkTitleWindowTitle -} diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.ts similarity index 85% rename from apps/desktop/electron/main.cjs rename to apps/desktop/electron/main.ts index 69d5d5f715b..e89e369bace 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.ts @@ -1,14 +1,24 @@ -const { + +import { execFile, execFileSync, spawn } from 'node:child_process' +import crypto from 'node:crypto' +import fs from 'node:fs' +import http from 'node:http' +import https from 'node:https' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { app, BrowserWindow, - Menu, - Notification, clipboard, dialog, + net as electronNet, ipcMain, + Menu, nativeImage, nativeTheme, - net: electronNet, + Notification, powerMonitor, protocol, safeStorage, @@ -16,58 +26,49 @@ const { session, shell, systemPreferences -} = require('electron') -const crypto = require('node:crypto') -const fs = require('node:fs') -const http = require('node:http') -const https = require('node:https') -const os = require('node:os') -const path = require('node:path') -const { pathToFileURL } = require('node:url') -const { execFileSync, spawn } = require('node:child_process') -const { installEmbedReferer } = require('./embed-referer.cjs') -const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') -const { runBootstrap } = require('./bootstrap-runner.cjs') -const { - buildSessionWindowUrl, - chatWindowWebPreferences, - createSessionWindowRegistry, - SESSION_WINDOW_MIN_HEIGHT, - SESSION_WINDOW_MIN_WIDTH -} = require('./session-windows.cjs') -const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') -const { - createLinkTitleWindow, - guardLinkTitleSession, - readLinkTitleWindowTitle -} = require('./link-title-window.cjs') -const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') -const { adoptServedDashboardToken } = require('./dashboard-token.cjs') -const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs') -const { dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs') -const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') -const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') -const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') -const { readWindowsUserEnvVar } = require('./windows-user-env.cjs') -const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs') -const { - nativeOverlayWidth: computeNativeOverlayWidth, - macTitleBarOverlayHeight -} = require('./titlebar-overlay-width.cjs') -const { readDirForIpc } = require('./fs-read-dir.cjs') -const { readLiveUpdateMarker, writeUpdateMarker } = require('./update-marker.cjs') -const { - resolveUnpackedRelease, - decideRelaunchOutcome, - sandboxPreflight, - sandboxFallbackFromEnv, - collectRelaunchArgs, - collectRelaunchEnv, - buildRelaunchScript -} = require('./update-relaunch.cjs') -const { gitRootForIpc } = require('./git-root.cjs') -const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs') -const { +} from 'electron' +import nodePty from 'node-pty' + +import { stopBackendChild as stopBackendChildImpl } from './backend-child' +import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' +import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' +import { canImportHermesCli, verifyHermesCli } from './backend-probes' +import { waitForDashboardPortAnnouncement } from './backend-ready' +import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' +import { runBootstrap } from './bootstrap-runner' +import { + authModeFromStatus, + buildGatewayWsUrl, + buildGatewayWsUrlWithTicket, + connectionScopeKey, + cookiesHaveLiveSession, + cookiesHavePrivySession, + cookiesHaveSession, + modeIsRemoteLike, + normalizeRemoteBaseUrl, + normAuthMode, + pathWithGlobalRemoteProfile, + profileRemoteOverride, + resolveAuthMode, + resolveTestWsUrl, + tokenPreview +} from './connection-config' +import { adoptServedDashboardToken } from './dashboard-token' +import { + buildPosixCleanupScript, + buildWindowsCleanupScript, + modeRemovesAgent, + modeRemovesUserData, + resolveRemovableAppPath, + shouldRemoveAppBundle, + uninstallArgsForMode +} from './desktop-uninstall' +import { installEmbedReferer } from './embed-referer' +import { readDirForIpc } from './fs-read-dir' +import { resolvePickerDefaultPath } from './wsl-path-bridge' +import { probeGatewayWebSocket } from './gateway-ws-probe' +import { scanGitRepos } from './git-repo-scan' +import { fileDiffVsHead, repoStatus, reviewCommit, @@ -76,87 +77,63 @@ const { reviewDiff, reviewList, reviewPush, - reviewRevParse, reviewRevert, + reviewRevParse, reviewShipInfo, reviewStage, reviewUnstage -} = require('./git-review-ops.cjs') -const { scanGitRepos } = require('./git-repo-scan.cjs') -const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') -const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') -const { runRebuildWithRetry } = require('./update-rebuild.cjs') -const { - buildPosixCleanupScript, - buildWindowsCleanupScript, - modeRemovesAgent, - modeRemovesUserData, - resolveRemovableAppPath, - shouldRemoveAppBundle, - uninstallArgsForMode -} = require('./desktop-uninstall.cjs') -const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs') -const { - MIN_WIDTH: WINDOW_MIN_WIDTH, - MIN_HEIGHT: WINDOW_MIN_HEIGHT, - sanitizeWindowState, - computeWindowOptions, - debounce -} = require('./window-state.cjs') -const { - authModeFromStatus, - buildGatewayWsUrl, - buildGatewayWsUrlWithTicket, - connectionScopeKey, - cookiesHaveSession, - cookiesHaveLiveSession, - normAuthMode, - normalizeRemoteBaseUrl, - pathWithGlobalRemoteProfile, - profileRemoteOverride, - resolveAuthMode, - resolveTestWsUrl, - tokenPreview -} = require('./connection-config.cjs') -const { +} from './git-review-ops' +import { gitRootForIpc } from './git-root' +import { addWorktree, listBaseBranches, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops' +import { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, - TEXT_PREVIEW_SOURCE_MAX_BYTES, - encryptDesktopSecret: encryptDesktopSecretStrict, + encryptDesktopSecret as encryptDesktopSecretStrict, resolveReadableFileForIpc, resolveRequestedPathForIpc, - resolveTimeoutMs -} = require('./hardening.cjs') - -let nodePty = null -let nodePtyDir = null - -try { - nodePty = require('node-pty') - nodePtyDir = path.dirname(require.resolve('node-pty/package.json')) -} catch { - // Packaged builds set `files:` in package.json, which excludes node_modules - // from the asar. Workspace dedup also hoists this native dep to the repo - // root's node_modules, out of reach of electron-builder's collector. We - // ship a minimal copy under resources/native-deps/ via extraResources + - // scripts/stage-native-deps.cjs; resolve from there when the normal - // require() fails. Dev mode never reaches this branch -- the hoisted - // resolve succeeds via Node's normal module lookup. - try { - const path = require('node:path') - const resourcesPath = process.resourcesPath - if (resourcesPath) { - nodePtyDir = path.join(resourcesPath, 'native-deps', 'node-pty') - nodePty = require(nodePtyDir) - } - } catch { - console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`) - nodePty = null - nodePtyDir = null - } -} + resolveTimeoutMs, + TEXT_PREVIEW_SOURCE_MAX_BYTES +} from './hardening' +import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' +import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' +import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' +import { + buildSessionWindowUrl, + chatWindowWebPreferences, + createSessionWindowRegistry, + SESSION_WINDOW_MIN_HEIGHT, + SESSION_WINDOW_MIN_WIDTH +} from './session-windows' +import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' +import { resolveBehindCount, shouldCountCommits } from './update-count' +import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker' +import { runRebuildWithRetry } from './update-rebuild' +import { + buildRelaunchScript, + collectRelaunchArgs, + collectRelaunchEnv, + decideRelaunchOutcome, + resolveUnpackedRelease, + sandboxFallbackFromEnv, + sandboxPreflight +} from './update-relaunch' +import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' +import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' +import { + computeWindowOptions, + debounce, + sanitizeWindowState, + MIN_HEIGHT as WINDOW_MIN_HEIGHT, + MIN_WIDTH as WINDOW_MIN_WIDTH +} from './window-state' +import { hiddenWindowsChildOptions } from './windows-child-options' +import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' +import { readWindowsUserEnvVar } from './windows-user-env' +import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd' +import { readWslWindowsClipboardImage } from './wsl-clipboard-image' const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR + if (USER_DATA_OVERRIDE) { const resolvedUserData = path.resolve(USER_DATA_OVERRIDE) fs.mkdirSync(resolvedUserData, { recursive: true }) @@ -164,7 +141,7 @@ if (USER_DATA_OVERRIDE) { } const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER -const IS_PACKAGED = app.isPackaged +const IS_PACKAGED = app.isPackaged || Boolean(process.env.HERMES_DESKTOP_IS_PACKAGED) const IS_MAC = process.platform === 'darwin' const IS_WINDOWS = process.platform === 'win32' const IS_WSL = isWslEnvironment() @@ -173,12 +150,10 @@ const IS_WSL = isWslEnvironment() const DARWIN_MAJOR = IS_MAC ? Number.parseInt(os.release(), 10) || 0 : 0 const APP_ROOT = app.getAppPath() -function hiddenWindowsChildOptions(options = {}) { - if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { - return options - } - return { ...options, windowsHide: true } -} +// Preload must be plain JS — Electron's sandbox can't run .ts, and tsx's +// ESM loader is broken on Electron 40's Node (ERR_INVALID_RETURN_PROPERTY_VALUE). +// Dev (`npm run dev`) and prod both load the esbuild output from dist/. +const PRELOAD_PATH = path.join(APP_ROOT, 'dist', 'electron-preload.js') // Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU // compositor flicker — accelerated layers can't be presented cleanly over the @@ -190,6 +165,7 @@ function hiddenWindowsChildOptions(options = {}) { // switches only apply pre-launch. Override with HERMES_DESKTOP_DISABLE_GPU // (1/true → always disable, 0/false → keep GPU on). const REMOTE_DISPLAY_REASON = detectRemoteDisplay() + if (REMOTE_DISPLAY_REASON) { app.disableHardwareAcceleration() // Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch @@ -229,7 +205,7 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') // Build-time install stamp -- the git ref this .exe was built against. // -// Written by apps/desktop/scripts/write-build-stamp.cjs during `npm run build` +// Written by apps/desktop/scripts/write-build-stamp.mjs during `npm run build` // and bundled into packaged apps via electron-builder's extraResources entry, // so the runtime stamp ends up at process.resourcesPath/install-stamp.json // after install. The bootstrap runner (Phase 1D) reads it to know which @@ -241,6 +217,7 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') // Schema: // { schemaVersion: 1, commit, branch, builtAt, dirty, source } const INSTALL_STAMP_SCHEMA_VERSION = 1 + function loadInstallStamp() { // Try packaged location first (resources/install-stamp.json), then the // dev/local build output (apps/desktop/build/install-stamp.json) so @@ -250,17 +227,21 @@ function loadInstallStamp() { process.resourcesPath ? path.join(process.resourcesPath, 'install-stamp.json') : null, path.join(APP_ROOT, 'build', 'install-stamp.json') ].filter(Boolean) + for (const p of candidates) { try { const raw = fs.readFileSync(p, 'utf8') const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && typeof parsed.commit === 'string' && parsed.commit.length >= 7) { if (parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) { console.warn( `[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring` ) + continue } + return Object.freeze({ schemaVersion: parsed.schemaVersion, commit: parsed.commit, @@ -271,13 +252,17 @@ function loadInstallStamp() { path: p }) } - } catch { + } catch (e) { + console.warn(`[hermes] install-stamp.json found at ${p} , but parsing failed with ${e}`) // Either ENOENT or malformed JSON; try the next candidate } } + return null } + const INSTALL_STAMP = loadInstallStamp() + if (INSTALL_STAMP) { console.log( `[hermes] install stamp: ${INSTALL_STAMP.commit.slice(0, 12)}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}` @@ -306,8 +291,14 @@ if (INSTALL_STAMP) { // HERMES_HOME beneath the throwaway userData dir so a fresh-install run never // touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes. function resolveHermesHome() { - if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME) - if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') + if (process.env.HERMES_HOME) { + return normalizeHermesHomeRoot(process.env.HERMES_HOME) + } + + if (USER_DATA_OVERRIDE) { + return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') + } + if (IS_WINDOWS) { // A GUI app launched from Explorer inherits the environment block captured // at login, so a HERMES_HOME set via `setx` AFTER login is invisible in @@ -316,16 +307,25 @@ function resolveHermesHome() { // inference provider configured" despite a valid configured home (#45471). // Consult the live User-scoped registry value before the default below. const fromRegistry = readWindowsUserEnvVar('HERMES_HOME') - if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry) + + if (fromRegistry) { + return normalizeHermesHomeRoot(fromRegistry) + } } + if (IS_WINDOWS && process.env.LOCALAPPDATA) { const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes') const legacy = path.join(app.getPath('home'), '.hermes') + // Migrate transparently to LOCALAPPDATA, but honour an existing legacy // ~/.hermes setup (no LOCALAPPDATA install yet) so users don't lose state. - if (!directoryExists(localappdata) && directoryExists(legacy)) return legacy + if (!directoryExists(localappdata) && directoryExists(legacy)) { + return legacy + } + return localappdata } + return path.join(app.getPath('home'), '.hermes') } @@ -338,6 +338,7 @@ function hermesManagedNodePathEntries() { const root = path.join(HERMES_HOME, 'node') const bin = path.join(root, 'bin') const entries = IS_WINDOWS ? [root, bin] : [bin, root] + return entries.filter(directoryExists) } @@ -408,19 +409,27 @@ const DESKTOP_LOG_BACKUP_COUNT = 3 const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4 const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}` const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1' + const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) - if (!Number.isFinite(raw) || raw <= 0) return 650 + + if (!Number.isFinite(raw) || raw <= 0) { + return 650 + } + return Math.max(120, raw) })() -const APP_NAME = 'Hermes' + +const APP_NAME = process.env.HERMES_DESKTOP_APP_NAME || 'Hermes' const TITLEBAR_HEIGHT = 34 const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14 + const WINDOW_BUTTON_POSITION = { x: 24, y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2 } -// Right-edge window-control reservation lives in titlebar-overlay-width.cjs + +// Right-edge window-control reservation lives in titlebar-overlay-width.ts // (pure + unit-testable); computeNativeOverlayWidth() applies it per platform. // It's only the pre-layout fallback — the renderer measures the exact overlay // width live via the Window Controls Overlay API. @@ -574,6 +583,7 @@ function getTitleBarOverlayOptions() { // setTitleBarOverlay isn't supported. function applyTitleBarOverlay(win) { const options = getTitleBarOverlayOptions() + if (!options || typeof options !== 'object') { return } @@ -611,6 +621,7 @@ const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm']) const PREVIEW_WATCH_DEBOUNCE_MS = 120 const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost']) const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 + const PREVIEW_LANGUAGE_BY_EXT = { '.c': 'c', '.conf': 'ini', @@ -647,14 +658,21 @@ const PREVIEW_LANGUAGE_BY_EXT = { } function looksBinary(buffer) { - if (!buffer.length) return false + if (!buffer.length) { + return false + } let suspicious = 0 for (const byte of buffer) { - if (byte === 0) return true + if (byte === 0) { + return true + } + // Allow common whitespace controls: tab, LF, CR. - if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) suspicious += 1 + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) { + suspicious += 1 + } } return suspicious / buffer.length > 0.12 @@ -691,6 +709,7 @@ function previewFileMetadata(filePath, mimeType) { } app.setName(APP_NAME) + // Windows toast notifications silently no-op unless an AppUserModelID is set: // `new Notification().show()` returns without error and nothing appears. The // AUMID must match the installed Start Menu shortcut's AUMID, which @@ -701,6 +720,7 @@ app.setName(APP_NAME) if (IS_WINDOWS) { app.setAppUserModelId('com.nousresearch.hermes') } + // Seed the native About panel with the live Hermes version. This is refreshed // on every open via the explicit "About" menu handler (refreshAboutPanel), so // an in-place `hermes update` mid-session is reflected without an app restart; @@ -718,6 +738,7 @@ app.setAboutPanelOptions({ // handler removes the size cap and gives the <video> element seekable, // range-aware playback. Must be registered before the app is ready. const MEDIA_PROTOCOL = 'hermes-media' + // Only audio/video may be streamed. Without this the handler would read any // non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`. const STREAMABLE_MEDIA_EXTS = new Set([ @@ -749,9 +770,12 @@ protocol.registerSchemesAsPrivileged([ function registerMediaProtocol() { protocol.handle(MEDIA_PROTOCOL, async request => { let resolvedPath + try { const url = new URL(request.url) + const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, '')) + ;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' })) } catch { return new Response('Media not found', { status: 404 }) @@ -774,6 +798,9 @@ function registerMediaProtocol() { let mainWindow = null let hermesProcess = null let connectionPromise = null +// True while connection-config:apply soft-rehomes the primary — suppresses the +// backend-exit toast so an intentional kill doesn't look like a crash. +let softRehomeInProgress = false // Additional per-profile backends, keyed by profile name. The PRIMARY backend // (the desktop's launch profile) stays managed by hermesProcess + // connectionPromise + startHermes(); this pool only holds EXTRA profile @@ -820,6 +847,7 @@ let desktopLogBuffer = '' let desktopLogFlushTimer = null let desktopLogFlushPromise = Promise.resolve() let nativeThemeListenerInstalled = false + let bootProgressState = { error: null, fakeMode: BOOT_FAKE_MODE, @@ -834,32 +862,45 @@ let bootProgressState = { // Each step is ['rm', path] or ['mv', src, dst]; executed best-effort so a // missing chain link never aborts the rest. function planDesktopLogRotation(size) { - if (size < DESKTOP_LOG_MAX_BYTES) return [] + if (size < DESKTOP_LOG_MAX_BYTES) { + return [] + } + const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1)) + // Pathological boot-loop log: reclaim live + every backup outright. if (size > DESKTOP_LOG_DISCARD_BYTES) { return [DESKTOP_LOG_PATH, ...backups(DESKTOP_LOG_BACKUP_COUNT)].map(p => ['rm', p]) } + // Cascade: drop oldest, shift each up, live -> .1. const ops = [['rm', desktopLogBackupPath(DESKTOP_LOG_BACKUP_COUNT)]] + for (let i = DESKTOP_LOG_BACKUP_COUNT - 1; i >= 1; i--) { ops.push(['mv', desktopLogBackupPath(i), desktopLogBackupPath(i + 1)]) } + ops.push(['mv', DESKTOP_LOG_PATH, desktopLogBackupPath(1)]) + return ops } function rotateDesktopLogIfNeededSync() { let size + try { size = fs.statSync(DESKTOP_LOG_PATH).size } catch { return // No live file yet — the append (re)creates it. } + for (const [op, src, dst] of planDesktopLogRotation(size)) { try { - if (op === 'rm') fs.rmSync(src, { force: true }) - else fs.renameSync(src, dst) + if (op === 'rm') { + fs.rmSync(src, { force: true }) + } else { + fs.renameSync(src, dst) + } } catch { // Best-effort — logging must never block startup/shutdown. } @@ -868,15 +909,20 @@ function rotateDesktopLogIfNeededSync() { async function rotateDesktopLogIfNeededAsync() { let size + try { size = (await fs.promises.stat(DESKTOP_LOG_PATH)).size } catch { return // No live file yet — the append (re)creates it. } + for (const [op, src, dst] of planDesktopLogRotation(size)) { try { - if (op === 'rm') await fs.promises.rm(src, { force: true }) - else await fs.promises.rename(src, dst) + if (op === 'rm') { + await fs.promises.rm(src, { force: true }) + } else { + await fs.promises.rename(src, dst) + } } catch { // Best-effort — logging must never crash the shell. } @@ -884,7 +930,10 @@ async function rotateDesktopLogIfNeededAsync() { } function flushDesktopLogBufferSync() { - if (!desktopLogBuffer) return + if (!desktopLogBuffer) { + return + } + const chunk = desktopLogBuffer desktopLogBuffer = '' @@ -898,7 +947,10 @@ function flushDesktopLogBufferSync() { } function flushDesktopLogBufferAsync() { - if (!desktopLogBuffer) return desktopLogFlushPromise + if (!desktopLogBuffer) { + return desktopLogFlushPromise + } + const chunk = desktopLogBuffer desktopLogBuffer = '' @@ -916,7 +968,10 @@ function flushDesktopLogBufferAsync() { } function scheduleDesktopLogFlush() { - if (desktopLogFlushTimer) return + if (desktopLogFlushTimer) { + return + } + desktopLogFlushTimer = setTimeout(() => { desktopLogFlushTimer = null void flushDesktopLogBufferAsync() @@ -925,9 +980,14 @@ function scheduleDesktopLogFlush() { function rememberLog(chunk) { const text = String(chunk || '').trim() - if (!text) return + + if (!text) { + return + } + const lines = text.split(/\r?\n/).map(line => `[hermes] ${line}`) hermesLog.push(...lines) + if (hermesLog.length > 300) { hermesLog.splice(0, hermesLog.length - 300) } @@ -939,6 +999,7 @@ function rememberLog(chunk) { clearTimeout(desktopLogFlushTimer) desktopLogFlushTimer = null } + void flushDesktopLogBufferAsync() return @@ -949,9 +1010,13 @@ function rememberLog(chunk) { function openExternalUrl(rawUrl) { const raw = String(rawUrl || '').trim() - if (!raw) return false + + if (!raw) { + return false + } let parsed + try { parsed = new URL(raw) } catch { @@ -965,6 +1030,7 @@ function openExternalUrl(rawUrl) { // string), fall back to revealing the file in the system file manager. if (parsed.protocol === 'file:') { let localPath + try { localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' }) } catch { @@ -999,11 +1065,13 @@ function openExternalUrl(rawUrl) { if (IS_WSL) { rememberLog(`[link] opening via WSL→Windows: ${url}`) + const proc = spawn('cmd.exe', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true }) + proc.on('error', error => { rememberLog(`[link] cmd.exe start failed: ${error.message}; falling back to xdg-open`) shell.openExternal(url).catch(fallback => rememberLog(`[link] xdg-open failed: ${fallback.message}`)) @@ -1020,9 +1088,13 @@ function openExternalUrl(rawUrl) { async function openPreviewInBrowser(rawUrl) { const raw = String(rawUrl || '').trim() - if (!raw) return false + + if (!raw) { + return false + } let parsed + try { parsed = new URL(raw) } catch { @@ -1031,6 +1103,7 @@ async function openPreviewInBrowser(rawUrl) { if (parsed.protocol === 'file:') { let localPath + try { localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open preview in browser' }) } catch { @@ -1046,7 +1119,9 @@ async function openPreviewInBrowser(rawUrl) { } function ensureWslWindowsFonts() { - if (!IS_WSL) return + if (!IS_WSL) { + return + } const fontsDir = ['/mnt/c/Windows/Fonts', '/mnt/c/windows/fonts'].find(candidate => { try { @@ -1055,18 +1130,25 @@ function ensureWslWindowsFonts() { return false } }) - if (!fontsDir) return + + if (!fontsDir) { + return + } try { const confDir = path.join(app.getPath('home'), '.config', 'fontconfig', 'conf.d') const confPath = path.join(confDir, '99-hermes-wsl-windows-fonts.conf') let existing = '' + try { existing = fs.readFileSync(confPath, 'utf8') } catch { existing = '' } - if (existing.includes(fontsDir)) return + + if (existing.includes(fontsDir)) { + return + } fs.mkdirSync(confDir, { recursive: true }) fs.writeFileSync( @@ -1089,14 +1171,25 @@ function sleep(ms) { function clampBootProgress(value) { const numeric = Number(value) - if (!Number.isFinite(numeric)) return 0 + + if (!Number.isFinite(numeric)) { + return 0 + } + return Math.max(0, Math.min(100, Math.round(numeric))) } function broadcastBootProgress() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:boot-progress', bootProgressState) } @@ -1120,6 +1213,7 @@ function broadcastBootProgress() { // 'Copy output' button gives the user actually-actionable context, not // just the last few lines. const BOOTSTRAP_LOG_RING_MAX = 500 + let bootstrapState = { active: false, manifest: null, @@ -1137,6 +1231,7 @@ function broadcastBootstrapEvent(ev) { bootstrapState.active = true bootstrapState.startedAt = bootstrapState.startedAt || Date.now() bootstrapState.stages = {} + for (const stage of ev.stages || []) { bootstrapState.stages[stage.name] = { state: 'pending', json: null, durationMs: null, error: null } } @@ -1149,6 +1244,7 @@ function broadcastBootstrapEvent(ev) { } } else if (ev.type === 'log') { bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line, stream: ev.stream || 'stdout' }) + if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) { bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX) } @@ -1170,9 +1266,16 @@ function broadcastBootstrapEvent(ev) { } } - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:bootstrap:event', ev) } @@ -1180,9 +1283,10 @@ function getBootstrapState() { return bootstrapState } -function updateBootProgress(update, options = {}) { +function updateBootProgress(update, options: { allowDecrease?: boolean } = {}) { const nextProgressRaw = typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress + const nextProgress = options.allowDecrease ? nextProgressRaw : Math.max(bootProgressState.progress, nextProgressRaw) bootProgressState = { @@ -1242,7 +1346,7 @@ function directoryExists(filePath) { // cycle loops. Instead the fresh instance parks until the update finishes, then // brings the backend up itself (it is the surviving instance — the updater's // own relaunch hits our single-instance lock and quits). Marker parsing + -// staleness self-heal live in update-marker.cjs (unit-tested). +// staleness self-heal live in update-marker.ts (unit-tested). // How long we'll park the launch waiting for a live update to finish before // giving up and starting the backend anyway (belt-and-suspenders alongside the @@ -1263,10 +1367,14 @@ const UPDATE_HANDOFF_DWELL_MS = 2500 // rather than a frozen splash. Returns true if it parked at all. async function waitForUpdateToFinish() { let marker = readLiveUpdateMarker(HERMES_HOME) - if (!marker) return false + + if (!marker) { + return false + } rememberLog(`[updates] update in progress (pid=${marker.pid}); deferring backend start until it finishes`) const deadline = Date.now() + UPDATE_WAIT_TIMEOUT_MS + while (marker && Date.now() < deadline) { await advanceBootProgress( 'backend.update-wait', @@ -1276,11 +1384,13 @@ async function waitForUpdateToFinish() { await new Promise(r => setTimeout(r, UPDATE_WAIT_POLL_MS)) marker = readLiveUpdateMarker(HERMES_HOME) } + if (marker) { rememberLog('[updates] update still in progress after wait timeout; starting backend anyway') } else { rememberLog('[updates] update finished; proceeding with backend start') } + return true } @@ -1289,31 +1399,41 @@ function unpackedPathFor(filePath) { } function findOnPath(command) { - if (!command) return null + if (!command) { + return null + } if (path.isAbsolute(command) || command.includes(path.sep) || (IS_WINDOWS && command.includes('/'))) { - if (!fileExists(command)) return null - if (isWindowsBinaryPathInWsl(command, { isWsl: IS_WSL })) return null + if (!fileExists(command)) { + return null + } + + if (isWindowsBinaryPathInWsl(command, { isWsl: IS_WSL })) { + return null + } + return command } const pathEntries = String(process.env.PATH || '') .split(path.delimiter) .filter(Boolean) + // On Windows, try PATHEXT extensions BEFORE the bare (empty-extension) name. // A real command must resolve via its .exe/.cmd (Windows command-resolution // semantics consult PATHEXT); an extensionless file — e.g. a Git-Bash // shell-script shim named `hermes` — must not shadow `hermes.cmd`/`hermes.exe`. // The empty entry is kept LAST so callers that already include the extension // (py.exe, pwsh.exe, powershell.exe) still resolve. - const extensions = IS_WINDOWS - ? [...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), ''] - : [''] + const extensions = buildPathExtCandidates(process.env.PATHEXT, IS_WINDOWS) for (const entry of pathEntries) { for (const extension of extensions) { const candidate = path.join(entry, `${command}${extension}`) - if (fileExists(candidate)) return candidate + + if (fileExists(candidate)) { + return candidate + } } } @@ -1325,57 +1445,21 @@ function isCommandScript(command) { } function unwrapWindowsVenvHermesCommand(command, backendArgs) { - if (!IS_WINDOWS || !command || isCommandScript(command)) return null - - const resolved = path.resolve(String(command)) - if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) return null - - const scriptsDir = path.dirname(resolved) - if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null - - const venvRoot = path.dirname(scriptsDir) - const python = getVenvPython(venvRoot) - if (!fileExists(python)) return null - - const root = path.dirname(venvRoot) - - // Smoke-test the venv interpreter before trusting it. A venv whose update - // died mid-`pip install` still has python.exe + hermes.exe on disk, but the - // backend dies on its first import (e.g. ModuleNotFoundError: dotenv) before - // the gateway ever binds. Returning it here also BYPASSED the caller's - // `--version` probe, so Retry/"Repair install" re-resolved the same broken - // venv forever instead of falling through to the bootstrap installer. - // Mirror isActiveRuntimeUsable(): probe with the checkout on PYTHONPATH so a - // healthy source-tree venv passes. - if ( - !canImportHermesCli(python, { - env: { - PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) - } - }) - ) { - rememberLog( - `Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.` - ) - return null - } - - return { - label: `existing Hermes Python at ${python}`, - command: python, - args: ['-m', 'hermes_cli.main', ...backendArgs], - bootstrap: false, - env: buildDesktopBackendEnv({ - hermesHome: HERMES_HOME, - pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)], - venvRoot - }), - kind: 'python', - // Surfaced so backendSupportsServe() can read this runtime's source for the - // `serve` capability check instead of falling back to a heavyweight probe. - root, - shell: false - } + return resolveVenvHermesCommand(command, backendArgs, { + isWindows: IS_WINDOWS, + isCommandScript, + fileExists, + directoryExists, + canImportHermesCli, + getVenvPython, + getVenvSitePackagesEntries, + buildDesktopBackendEnv, + hermesHome: HERMES_HOME, + resolvePath: (...segments) => path.resolve(...segments), + dirname: p => path.dirname(p), + basename: p => path.basename(p), + rememberLog + }) } // Does the resolved runtime understand the `serve` subcommand? The desktop @@ -1389,12 +1473,20 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { // (covers a bare `hermes` resolved from PATH with no known source root). Result // is cached per resolved runtime so we probe at most once per backend. const _serveSupportCache = new Map() + function backendSupportsServe(backend) { - if (!backend || !backend.command) return true + if (!backend || !backend.command) { + return true + } + const key = `${backend.command}::${backend.root || ''}` - if (_serveSupportCache.has(key)) return _serveSupportCache.get(key) + + if (_serveSupportCache.has(key)) { + return _serveSupportCache.get(key) + } let supported = null + if (backend.root) { try { const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8') @@ -1424,6 +1516,7 @@ function backendSupportsServe(backend) { rememberLog( `[backend] \`serve\` ${supported ? 'supported' : 'unsupported → routing via legacy `dashboard`'} for ${backend.label || key}` ) + return supported } @@ -1435,9 +1528,12 @@ function getBackendArgsForRuntime(backend) { } function normalizeExecutablePathForCompare(commandPath) { - if (!commandPath) return null + if (!commandPath) { + return null + } let resolved = path.resolve(String(commandPath)) + try { resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) } catch { @@ -1448,15 +1544,19 @@ function normalizeExecutablePathForCompare(commandPath) { } function looksLikeDesktopAppBinary(commandPath) { - if (!IS_WINDOWS || !commandPath) return false + if (!IS_WINDOWS || !commandPath) { + return false + } const normalizedCandidate = normalizeExecutablePathForCompare(commandPath) const normalizedCurrentExec = normalizeExecutablePathForCompare(process.execPath) + if (normalizedCandidate && normalizedCurrentExec && normalizedCandidate === normalizedCurrentExec) { return true } let resolved = path.resolve(String(commandPath)) + try { resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) } catch { @@ -1464,6 +1564,7 @@ function looksLikeDesktopAppBinary(commandPath) { } const resourcesDir = path.join(path.dirname(resolved), 'resources') + return ( fileExists(path.join(resourcesDir, 'app.asar')) || directoryExists(path.join(resourcesDir, 'app.asar.unpacked')) ) @@ -1475,7 +1576,10 @@ function isHermesSourceRoot(root) { function findPythonForRoot(root) { const override = process.env.HERMES_DESKTOP_PYTHON - if (override && fileExists(override)) return override + + if (override && fileExists(override)) { + return override + } const relativePaths = IS_WINDOWS ? [path.join('.venv', 'Scripts', 'python.exe'), path.join('venv', 'Scripts', 'python.exe')] @@ -1483,7 +1587,10 @@ function findPythonForRoot(root) { for (const relativePath of relativePaths) { const candidate = path.join(root, relativePath) - if (fileExists(candidate)) return candidate + + if (fileExists(candidate)) { + return candidate + } } return findSystemPython() @@ -1494,8 +1601,12 @@ function findSystemPython() { // POSIX systems: PATH lookup is safe. for (const command of ['python3', 'python']) { const candidate = findOnPath(command) - if (candidate) return candidate + + if (candidate) { + return candidate + } } + return null } @@ -1551,12 +1662,17 @@ function findSystemPython() { ['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'], hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) ) + // Output format: " (Default) REG_SZ C:\Path\To\Python\" const match = out.match(/REG_SZ\s+(.+?)\s*$/m) + if (match) { const installPath = match[1].trim() const pythonExe = path.join(installPath, 'python.exe') - if (fileExists(pythonExe)) return pythonExe + + if (fileExists(pythonExe)) { + return pythonExe + } } } catch { // Key not present — try next. @@ -1567,12 +1683,20 @@ function findSystemPython() { // Pass 2: filesystem probe of standard locations. const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files' const localAppData = process.env.LOCALAPPDATA || '' + for (const versionDir of SUPPORTED_VERSIONS_NO_DOT) { const systemWide = path.join(programFiles, `Python${versionDir}`, 'python.exe') - if (fileExists(systemWide)) return systemWide + + if (fileExists(systemWide)) { + return systemWide + } + if (localAppData) { const perUser = path.join(localAppData, 'Programs', 'Python', `Python${versionDir}`, 'python.exe') - if (fileExists(perUser)) return perUser + + if (fileExists(perUser)) { + return perUser + } } } @@ -1582,6 +1706,7 @@ function findSystemPython() { // the requested version. We try in version-priority order so the // first hit wins. const pyExe = findOnPath('py.exe') + if (pyExe) { for (const version of SUPPORTED_VERSIONS) { try { @@ -1593,8 +1718,12 @@ function findSystemPython() { stdio: ['ignore', 'pipe', 'ignore'] }) ) + const candidate = out.trim() - if (candidate && fileExists(candidate)) return candidate + + if (candidate && fileExists(candidate)) { + return candidate + } } catch { // py couldn't find that version — try next. } @@ -1628,6 +1757,7 @@ function findGitBash() { // start probing system-wide locations. const localAppData = process.env.LOCALAPPDATA || '' const candidates = [] + if (localAppData) { candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'bash.exe')) candidates.push(path.join(localAppData, 'hermes', 'git', 'usr', 'bin', 'bash.exe')) @@ -1636,12 +1766,15 @@ function findGitBash() { // Standard Git for Windows install locations. candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'bin', 'bash.exe')) candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe')) + if (localAppData) { candidates.push(path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe')) } for (const candidate of candidates) { - if (fileExists(candidate)) return candidate + if (fileExists(candidate)) { + return candidate + } } // Last resort — bash on PATH (covers WSL bash, MSYS2, custom installs). @@ -1675,35 +1808,10 @@ function getVenvPython(venvRoot) { // normal HERMES_DASHBOARD_READY stdout line and no ready-file side channel is // needed. -function getVenvSitePackagesEntries(venvRoot) { - const entries = [] - if (!venvRoot) return entries - - if (IS_WINDOWS) { - const sitePackages = path.join(venvRoot, 'Lib', 'site-packages') - if (directoryExists(sitePackages)) entries.push(sitePackages) - return entries - } - - const version = (() => { - try { - const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8') - const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im) - return match ? match[1].trim() : null - } catch { - return null - } - })() - if (version) { - const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages') - if (directoryExists(sitePackages)) entries.push(sitePackages) - } - return entries -} - function makeDashboardReadyFile() { const dir = path.join(app.getPath('userData'), 'backend-ready') fs.mkdirSync(dir, { recursive: true }) + return path.join(dir, `dashboard-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.json`) } @@ -1713,26 +1821,35 @@ function makeDashboardReadyFile() { // "Couldn't check for updates". Mirror findGitBash: PortableGit first, then // standard Git-for-Windows locations, then PATH. Cached after first probe. let _gitBinaryCache = null + function resolveGitBinary() { - if (_gitBinaryCache) return _gitBinaryCache + if (_gitBinaryCache) { + return _gitBinaryCache + } + if (!IS_WINDOWS) { _gitBinaryCache = findOnPath('git') || 'git' + return _gitBinaryCache } const localAppData = process.env.LOCALAPPDATA || '' const candidates = [] + if (localAppData) { candidates.push(path.join(localAppData, 'hermes', 'git', 'cmd', 'git.exe')) candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'git.exe')) } + candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'cmd', 'git.exe')) candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'cmd', 'git.exe')) + if (localAppData) { candidates.push(path.join(localAppData, 'Programs', 'Git', 'cmd', 'git.exe')) } _gitBinaryCache = candidates.find(fileExists) || findOnPath('git') || 'git' + return _gitBinaryCache } @@ -1741,13 +1858,17 @@ function resolveGitBinary() { // lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's // terminal. Check the common install locations first, then PATH. Cached. let _ghBinaryCache = null + function resolveGhBinary() { - if (_ghBinaryCache) return _ghBinaryCache + if (_ghBinaryCache) { + return _ghBinaryCache + } const candidates = [] if (IS_WINDOWS) { candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe')) + if (process.env.LOCALAPPDATA) { candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe')) } @@ -1757,6 +1878,7 @@ function resolveGhBinary() { } _ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh' + return _ghBinaryCache } @@ -1770,6 +1892,7 @@ function readDesktopUpdateConfig() { try { const parsed = JSON.parse(fs.readFileSync(DESKTOP_UPDATE_CONFIG_PATH, 'utf8')) const branch = typeof parsed?.branch === 'string' ? parsed.branch.trim() : '' + return { branch: branch || DEFAULT_UPDATE_BRANCH } } catch { return { branch: DEFAULT_UPDATE_BRANCH } @@ -1778,7 +1901,7 @@ function readDesktopUpdateConfig() { // Atomic file write: temp + rename (atomic on all platforms). Prevents // partial writes on crash/power loss that corrupt JSON config files. -function writeFileAtomic(targetPath, data, encoding) { +function writeFileAtomic(targetPath, data, encoding?: BufferEncoding) { const tmp = targetPath + '.tmp' fs.writeFileSync(tmp, data, encoding) fs.renameSync(tmp, targetPath) @@ -1803,7 +1926,10 @@ function readWindowState() { // getNormalBounds() keeps the pre-maximize size, so un-maximizing next session // lands back where the user actually sized the window. function persistWindowState() { - if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) return + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) { + return + } + try { const { x, y, width, height } = mainWindow.getNormalBounds() fs.mkdirSync(path.dirname(DESKTOP_WINDOW_STATE_PATH), { recursive: true }) @@ -1832,14 +1958,14 @@ function resolveUpdateRoot() { return candidates.find(c => directoryExists(path.join(c, '.git'))) || candidates[0] || ACTIVE_HERMES_ROOT } -function runGit(args, options = {}) { +function runGit(args, options: any = {}): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn( resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({ cwd: options.cwd, - env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' }, + env: { ...process.env, ...((options.env || {}) as any), GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] }) ) @@ -1865,12 +1991,14 @@ const firstLine = text => (text || '').split('\n').find(Boolean) || '' async function getOriginUrl(updateRoot) { const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot }) + return origin.code === 0 ? origin.stdout.trim() : '' } function emitUpdateProgress(payload) { const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() } rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`) + for (const window of BrowserWindow.getAllWindows()) { window.webContents.send('hermes:updates:progress', merged) } @@ -1890,15 +2018,18 @@ async function resolveHealedBranch(updateRoot, branch) { const originUrl = await getOriginUrl(updateRoot) const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin' const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot }) + if (probe.code !== 2) { return branch } rememberLog(`[updates] origin/${branch} is gone (merged?); falling back to main`) const config = readDesktopUpdateConfig() + if (config.branch !== 'main') { writeDesktopUpdateConfig({ ...config, branch: 'main' }) } + return 'main' } @@ -1906,6 +2037,7 @@ async function checkUpdates() { const updateRoot = resolveUpdateRoot() let { branch } = readDesktopUpdateConfig() const gitDir = path.join(updateRoot, '.git') + if (!directoryExists(gitDir)) { return { supported: false, @@ -1918,15 +2050,19 @@ async function checkUpdates() { branch = await resolveHealedBranch(updateRoot, branch) const originUrl = await getOriginUrl(updateRoot) + if (isOfficialSshRemote(originUrl)) { const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) + const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([ git(['rev-parse', 'HEAD']), runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }), git(['status', '--porcelain']), git(['rev-parse', '--abbrev-ref', 'HEAD']) ]) + const targetSha = firstLine(target.stdout).split(/\s+/)[0] || '' + if (target.code !== 0 || !targetSha) { return { supported: true, @@ -1937,6 +2073,7 @@ async function checkUpdates() { fetchedAt: Date.now() } } + return { supported: true, branch, @@ -1952,6 +2089,7 @@ async function checkUpdates() { } const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot }) + if (fetched.code !== 0) { return { supported: true, @@ -1964,6 +2102,7 @@ async function checkUpdates() { } const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) + const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr, mergeBaseStr] = await Promise.all([ git(['rev-parse', 'HEAD']), git(['rev-parse', `origin/${branch}`]), @@ -1977,6 +2116,7 @@ async function checkUpdates() { const isShallow = shallowStr === 'true' const hasMergeBase = Boolean(mergeBaseStr) + // Only enumerate the commit count when it is meaningful. On a shallow checkout // with no merge-base, `rev-list --count` walks the entire remote ancestry // (thousands of commits, see #51922) and resolveBehindCount discards the @@ -1992,6 +2132,7 @@ async function checkUpdates() { isShallow, hasMergeBase }) + const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : [] return { @@ -2011,6 +2152,7 @@ async function checkUpdates() { async function readCommitLog(cwd, branch) { const SEP = '\x1f' const REC = '\x1e' + const { stdout } = await runGit( ['log', `HEAD..origin/${branch}`, `--pretty=format:%H${SEP}%s${SEP}%an${SEP}%at${REC}`, '-n', '40'], { cwd } @@ -2022,6 +2164,7 @@ async function readCommitLog(cwd, branch) { .filter(Boolean) .map(line => { const [sha, summary, author, at] = line.split(SEP) + return { sha, summary, author, at: Number.parseInt(at, 10) * 1000 } }) } @@ -2048,11 +2191,14 @@ let isQuittingForHandoff = false function resolveUpdaterBinary() { const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup' const candidate = path.join(HERMES_HOME, name) + return fileExists(candidate) ? candidate : null } function repairMacUpdaterHelper(updater) { - if (!IS_MAC || !updater) return + if (!IS_MAC || !updater) { + return + } try { execFileSync('/usr/bin/xattr', ['-cr', updater], { stdio: 'ignore' }) @@ -2062,6 +2208,7 @@ function repairMacUpdaterHelper(updater) { try { execFileSync('/usr/bin/codesign', ['--verify', updater], { stdio: 'ignore' }) + return } catch { // Unsigned or invalid helper. Apply a local ad-hoc signature so Gatekeeper @@ -2090,10 +2237,15 @@ function venvHermesShimPath(updateRoot) { // this practically always succeeds (no mandatory locking), so it returns false // — correct, since the shim-contention brick is Windows-only. function isShimLocked(shimPath) { - if (!IS_WINDOWS) return false + if (!IS_WINDOWS) { + return false + } + let fd + try { fd = fs.openSync(shimPath, 'r+') + return false } catch (err) { // ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/ @@ -2119,8 +2271,14 @@ function isShimLocked(shimPath) { // not a process-group leader — a POSIX negative-pgid kill would be meaningless // here anyway). POSIX teardown stays with the existing before-quit SIGTERM. function forceKillProcessTree(pid) { - if (!IS_WINDOWS) return - if (!Number.isInteger(pid) || pid <= 0) return + if (!IS_WINDOWS) { + return + } + + if (!Number.isInteger(pid) || pid <= 0) { + return + } + try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' })) } catch { @@ -2160,13 +2318,21 @@ async function releaseBackendLockForUpdate(updateRoot) { // `tag` only flavors the log lines. No-op off Windows (POSIX has no mandatory // locks — the before-quit SIGTERM + the cleanup script's own PID-wait suffice). async function releaseBackendLock(updateRoot, tag) { - if (!IS_WINDOWS) return { unlocked: true } + if (!IS_WINDOWS) { + return { unlocked: true } + } // Collect every backend PID the desktop owns: primary window backend + pool. const pids = [] - if (hermesProcess && Number.isInteger(hermesProcess.pid)) pids.push(hermesProcess.pid) + + if (hermesProcess && Number.isInteger(hermesProcess.pid)) { + pids.push(hermesProcess.pid) + } + for (const entry of backendPool.values()) { - if (entry.process && Number.isInteger(entry.process.pid)) pids.push(entry.process.pid) + if (entry.process && Number.isInteger(entry.process.pid)) { + pids.push(entry.process.pid) + } } // Graceful first (lets Python flush), then tree-kill to catch grandchildren. @@ -2177,27 +2343,45 @@ async function releaseBackendLock(updateRoot, tag) { void 0 } } + stopAllPoolBackends() - for (const pid of pids) forceKillProcessTree(pid) + + for (const pid of pids) { + forceKillProcessTree(pid) + } const shim = venvHermesShimPath(updateRoot) const deadlineMs = Date.now() + 15000 + while (Date.now() < deadlineMs) { if (!isShimLocked(shim)) { rememberLog(`[${tag}] venv shim unlocked; safe to proceed`) + return { unlocked: true } } + // A supervised backend can respawn between kill and check (grandchildren, // pool entries registered mid-teardown). Re-collect and re-kill each pass // instead of trusting the initial sweep. const stragglers = [] - if (hermesProcess && Number.isInteger(hermesProcess.pid)) stragglers.push(hermesProcess.pid) - for (const entry of backendPool.values()) { - if (entry.process && Number.isInteger(entry.process.pid)) stragglers.push(entry.process.pid) + + if (hermesProcess && Number.isInteger(hermesProcess.pid)) { + stragglers.push(hermesProcess.pid) } - for (const pid of stragglers) forceKillProcessTree(pid) + + for (const entry of backendPool.values()) { + if (entry.process && Number.isInteger(entry.process.pid)) { + stragglers.push(entry.process.pid) + } + } + + for (const pid of stragglers) { + forceKillProcessTree(pid) + } + await new Promise(r => setTimeout(r, 300)) } + // Do NOT proceed past a held lock: handing off to the updater while another // process (a second desktop window, a user terminal, an unkillable child) // still maps the venv's files guarantees a half-updated venv — the updater's @@ -2205,7 +2389,10 @@ async function releaseBackendLock(updateRoot, tag) { // imports broken (the July 2026 brotlicffi/_sodium.pyd incidents). Failing // the update loudly and keeping the app running is strictly better than a // bricked install that needs manual venv surgery. - rememberLog(`[${tag}] venv shim still locked after 15s; aborting hand-off (something outside this app holds the venv)`) + rememberLog( + `[${tag}] venv shim still locked after 15s; aborting hand-off (something outside this app holds the venv)` + ) + return { unlocked: false } } @@ -2223,10 +2410,12 @@ async function applyUpdates(opts = {}) { if (updateInFlight) { throw new Error('An update is already in progress.') } + updateInFlight = true try { const updater = resolveUpdaterBinary() + if (!updater && !IS_WINDOWS) { // macOS/Linux drag-install: no staged Tauri hermes-setup. Unlike Windows // (where a venv-shim file lock forces the quit→hand-off→rebuild dance), @@ -2236,6 +2425,7 @@ async function applyUpdates(opts = {}) { // with the freshly built one and relaunch. return await applyUpdatesPosixInApp(opts) } + if (!updater) { // No staged updater binary — this is a CLI-installed user (they ran // `hermes desktop`, never the Tauri installer that self-copies @@ -2248,18 +2438,25 @@ async function applyUpdates(opts = {}) { // checkouts, keep it bare for main so the card stays clean. const updateRoot = resolveUpdateRoot() let command = 'hermes update' + try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const current = (head.stdout || '').trim() + if (head.code === 0 && current && current !== 'HEAD') { const branch = await resolveHealedBranch(updateRoot, current) - if (branch !== 'main') command = `hermes update --branch ${branch}` + + if (branch !== 'main') { + command = `hermes update --branch ${branch}` + } } } catch { // Best-effort: fall back to bare `hermes update` if branch detection fails. } + rememberLog(`[updates] no staged updater; surfacing manual \`${command}\` for CLI install at ${updateRoot}`) emitUpdateProgress({ stage: 'manual', message: command, percent: null }) + return { ok: true, manual: true, command, hermesRoot: updateRoot } } @@ -2276,9 +2473,11 @@ async function applyUpdates(opts = {}) { const branch = await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) const updaterArgs = ['--update', '--branch', branch] const targetApp = IS_MAC ? runningAppBundle() : null + if (targetApp) { updaterArgs.push('--target-app', targetApp) } + const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') // Stop our own backend(s) and wait for the venv shim to unlock BEFORE we @@ -2286,6 +2485,7 @@ async function applyUpdates(opts = {}) { // hermes.exe (held by the backend child / its grandchildren) and the update // bricks. See releaseBackendLockForUpdate for the full failure analysis. const lock = await releaseBackendLockForUpdate(updateRoot) + if (!lock.unlocked) { // Something OUTSIDE this app holds the venv (a second window, a user // terminal running hermes, an unkillable child). Handing off anyway @@ -2295,8 +2495,10 @@ async function applyUpdates(opts = {}) { const message = 'Update aborted: another process is holding the Hermes install open ' + '(a second Hermes window or a terminal running hermes?). Close it and retry.' + emitUpdateProgress({ stage: 'error', message, percent: null }) startHermes().catch(() => {}) + return { ok: false, error: message } } @@ -2313,6 +2515,7 @@ async function applyUpdates(opts = {}) { stdio: 'ignore', windowsHide: false }) + child.unref() // Write the update-in-progress marker IMMEDIATELY — before the 2.5s @@ -2345,19 +2548,27 @@ async function applyUpdates(opts = {}) { } async function handOffWindowsBootstrapRecovery(reason) { - if (!IS_WINDOWS || !IS_PACKAGED) return false + if (!IS_WINDOWS || !IS_PACKAGED) { + return false + } const updater = resolveUpdaterBinary() - if (!updater) return false + + if (!updater) { + return false + } const updateRoot = resolveUpdateRoot() const { branch: configuredBranch } = readDesktopUpdateConfig() + const branch = directoryExists(path.join(updateRoot, '.git')) ? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) : configuredBranch || DEFAULT_UPDATE_BRANCH + const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') + // Choose the gentle in-place --update when ANY real-install signal is present, // not just the `hermes.exe` console-script shim. That shim is generated at the // END of venv setup and is absent in exactly the interrupted/quarantined states @@ -2366,7 +2577,8 @@ async function handOffWindowsBootstrapRecovery(reason) { // and the bootstrap-complete marker are present earlier and are better signals. const haveRealInstall = fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')) - const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] + + const updaterArgs = chooseUpdaterArgs(haveRealInstall, branch) await releaseBackendLockForUpdate(updateRoot) @@ -2381,6 +2593,7 @@ async function handOffWindowsBootstrapRecovery(reason) { stdio: 'ignore', windowsHide: false }) + child.unref() // Same marker pre-write as applyUpdates — see comment there. The recovery @@ -2408,14 +2621,19 @@ async function handOffWindowsBootstrapRecovery(reason) { // the install we're updating, fall back to `hermes` on PATH. function resolveHermesCliBinary(updateRoot) { const venvHermes = path.join(updateRoot, 'venv', 'bin', 'hermes') - if (fileExists(venvHermes)) return venvHermes + + if (fileExists(venvHermes)) { + return venvHermes + } + return findOnPath('hermes') || null } // Spawn a command and stream each output line to the update progress channel. -function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { +function runStreamedUpdate(command, args, { cwd, env, stage }: any = {}) { return new Promise(resolve => { let child + try { child = spawn( command, @@ -2428,14 +2646,20 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { ) } catch (err) { resolve({ code: 1, error: err.message }) + return } + const emitLines = chunk => { for (const line of chunk.toString().split('\n')) { const trimmed = line.trim() - if (trimmed) emitUpdateProgress({ stage, message: trimmed, percent: null }) + + if (trimmed) { + emitUpdateProgress({ stage, message: trimmed, percent: null }) + } } } + child.stdout.on('data', emitLines) child.stderr.on('data', emitLines) child.once('error', err => resolve({ code: 1, error: err.message })) @@ -2446,9 +2670,16 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { // The running app's .app bundle (packaged macOS): execPath is // <App>.app/Contents/MacOS/<exe>; climb three levels to the bundle root. function runningAppBundle() { - if (!IS_MAC) return null + if (!IS_MAC) { + return null + } + let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS - for (let i = 0; i < 2; i++) dir = path.dirname(dir) // -> .../X.app + + for (let i = 0; i < 2; i++) { + dir = path.dirname(dir) + } // -> .../X.app + return dir.endsWith('.app') ? dir : null } @@ -2460,18 +2691,20 @@ function shellQuote(value) { // (`hermes desktop --build-only`), then atomically swap the running .app bundle // with the freshly built one and relaunch. Degrades to "backend updated, // restart to load the new GUI" if the swap can't be performed. -async function applyUpdatesPosixInApp() { +async function applyUpdatesPosixInApp(opts: any) { const updateRoot = resolveUpdateRoot() const hermes = resolveHermesCliBinary(updateRoot) + if (!hermes) { emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null }) + return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot } } // Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s // npm build can find them on a machine with no system Node. Windows portable // Node lives directly under %LOCALAPPDATA%\hermes\node, not node\bin. - const env = { + const env: Record<string, string> = { HERMES_HOME, PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) } @@ -2488,14 +2721,17 @@ async function applyUpdatesPosixInApp() { // the update reaper. _kill_stale_dashboard_processes accepts a comma-separated // list (a single int still parses for back-compat). const desktopChildPids = [] + if (hermesProcess && Number.isInteger(hermesProcess.pid)) { desktopChildPids.push(hermesProcess.pid) } + for (const entry of backendPool.values()) { if (entry.process && Number.isInteger(entry.process.pid)) { desktopChildPids.push(entry.process.pid) } } + if (desktopChildPids.length) { env.HERMES_DESKTOP_CHILD_PID = desktopChildPids.join(',') } @@ -2503,9 +2739,11 @@ async function applyUpdatesPosixInApp() { // Branch-pin so a non-main checkout doesn't get switched to main (and self-heal // to main when the pinned branch no longer exists on origin). let branchArgs = [] + try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const current = (head.stdout || '').trim() + if (head.code === 0 && current && current !== 'HEAD') { branchArgs = ['--branch', await resolveHealedBranch(updateRoot, current)] } @@ -2514,17 +2752,21 @@ async function applyUpdatesPosixInApp() { } emitUpdateProgress({ stage: 'update', message: 'Updating Hermes (git + dependencies)…', percent: 10 }) - const updated = await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], { + + const updated = (await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], { cwd: updateRoot, env, stage: 'update' - }) + })) as any + if (updated.code !== 0) { emitUpdateProgress({ stage: 'error', message: 'hermes update failed.', error: updated.error || 'update-failed' }) + return { ok: false, error: 'hermes update failed' } } emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 }) + // Retry-once: a first rebuild can fail on a still-settling tree or a // self-healed (network-blocked) Electron download; a second run builds clean // off the healed dist so we reach the swap+relaunch below instead of bailing. @@ -2532,14 +2774,17 @@ async function applyUpdatesPosixInApp() { if (attempt > 0) { emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 }) } + return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' }) }) + if (rebuilt.code !== 0) { emitUpdateProgress({ stage: 'error', message: 'Backend updated, but the desktop rebuild failed. Restart Hermes to retry.', error: rebuilt.error || 'rebuild-failed' }) + return { ok: false, backendUpdated: true, error: 'desktop rebuild failed' } } @@ -2547,7 +2792,7 @@ async function applyUpdatesPosixInApp() { // rebuilds the unpacked app in place under apps/desktop/release/<plat>-unpacked. // We can only HONESTLY relaunch into the new GUI when the *running* binary IS // that rebuilt one — i.e. execPath lives under release/<plat>-unpacked. The - // outcome is decided by three signals (see update-relaunch.cjs): + // outcome is decided by three signals (see update-relaunch.ts): // // underUnpacked + sandboxOk → 'relaunch': detached watcher re-execs us in // place (mirrors the macOS handoff). Without it the update succeeds but @@ -2569,8 +2814,10 @@ async function applyUpdatesPosixInApp() { const preflight = underUnpacked ? sandboxPreflight(unpackedDir, p => fs.statSync(p)) : { ok: false, reason: 'not-under-unpacked', path: null } + const sandboxFallback = sandboxFallbackFromEnv(process.env, process.argv.slice(1)) const sandboxOk = preflight.ok || sandboxFallback + if (underUnpacked && !preflight.ok) { rememberLog( `[updates] sandbox preflight: not launchable (${preflight.reason}) at ${preflight.path}; ` + @@ -2588,6 +2835,7 @@ async function applyUpdatesPosixInApp() { // relaunched instance comes up with default context instead of the user's. const relaunchArgs = collectRelaunchArgs(process.argv.slice(1)) const relaunchEnv = collectRelaunchEnv(process.env) + const relaunchScript = buildRelaunchScript({ pid: process.pid, execPath: process.execPath, @@ -2595,7 +2843,9 @@ async function applyUpdatesPosixInApp() { env: relaunchEnv, cwd: process.cwd() }) + const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`) + try { fs.writeFileSync(scriptPath, relaunchScript, { mode: 0o755 }) const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' }) @@ -2606,9 +2856,11 @@ async function applyUpdatesPosixInApp() { ) isQuittingForHandoff = true setTimeout(() => app.quit(), UPDATE_HANDOFF_DWELL_MS) + return { ok: true, handedOff: true } } catch (err) { rememberLog(`[updates] linux relaunch failed: ${err.message}; falling back to manual restart`) + return { ok: true, backendUpdated: true, @@ -2631,6 +2883,7 @@ async function applyUpdatesPosixInApp() { `[updates] gui/backend skew: execPath ${process.execPath} not under release/*-unpacked; ` + 'backend updated, GUI package unchanged (AppImage/.deb/.rpm/dev/unresolved)' ) + return { ok: true, backendUpdated: true, guiUpdated: false, guiSkew: true } } @@ -2640,6 +2893,7 @@ async function applyUpdatesPosixInApp() { `[updates] sandbox not launchable (${preflight.reason}); skipping auto-relaunch, ` + 'returning manual-restart so the user keeps a working window' ) + return { ok: true, backendUpdated: true, @@ -2656,6 +2910,7 @@ async function applyUpdatesPosixInApp() { path.join(updateRoot, 'apps', 'desktop', 'release', 'mac-arm64', 'Hermes.app'), path.join(updateRoot, 'apps', 'desktop', 'release', 'mac', 'Hermes.app') ].find(directoryExists) + const targetApp = runningAppBundle() // No bundle to swap (dev run, Linux AppImage, or unresolved paths): the @@ -2666,6 +2921,7 @@ async function applyUpdatesPosixInApp() { message: 'Backend updated. Restart Hermes to load the new version.', percent: 100 }) + return { ok: true, backendUpdated: true, rebuiltApp: rebuiltApp || null } } @@ -2693,7 +2949,9 @@ fi /usr/bin/xattr -dr com.apple.quarantine "$DST" 2>/dev/null || true /usr/bin/open "$DST" ` + const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`) + try { fs.writeFileSync(scriptPath, swapScript, { mode: 0o755 }) } catch (err) { @@ -2703,6 +2961,7 @@ fi percent: 100 }) rememberLog(`[updates] could not write swap script: ${err.message}; rebuilt app at ${rebuiltApp}`) + return { ok: true, backendUpdated: true, rebuiltApp } } @@ -2712,6 +2971,7 @@ fi isQuittingForHandoff = true setTimeout(() => app.quit(), 600) + return { ok: true, handedOff: true, rebuiltApp, targetApp } } @@ -2748,6 +3008,7 @@ function readBootstrapMarker() { // "already installed" off the filesystem alone, not just the marker. function isActiveRuntimeUsable() { const venvPython = getVenvPython(VENV_ROOT) + return ( isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(venvPython) && @@ -2761,9 +3022,19 @@ function isActiveRuntimeUsable() { function isBootstrapComplete() { const marker = readBootstrapMarker() - if (!marker || typeof marker !== 'object') return false - if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false - if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false + + if (!marker || typeof marker !== 'object') { + return false + } + + if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) { + return false + } + + if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) { + return false + } + // We DELIBERATELY do NOT verify that the checkout is currently at the // pinned commit -- users update via the in-app update path or `hermes // update`, which moves HEAD legitimately. The marker just attests "we @@ -2776,6 +3047,7 @@ function isBootstrapComplete() { function writeBootstrapMarker(payload) { fs.mkdirSync(path.dirname(BOOTSTRAP_COMPLETE_MARKER), { recursive: true }) + const merged = { schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION, pinnedCommit: payload.pinnedCommit || null, @@ -2783,16 +3055,24 @@ function writeBootstrapMarker(payload) { completedAt: new Date().toISOString(), desktopVersion: app.getVersion() } + writeFileAtomic(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8') + return merged } function resolveWebDist() { const override = process.env.HERMES_DESKTOP_WEB_DIST - if (override && directoryExists(path.resolve(override))) return path.resolve(override) + + if (override && directoryExists(path.resolve(override))) { + return path.resolve(override) + } const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist') - if (directoryExists(unpackedDist)) return unpackedDist + + if (directoryExists(unpackedDist)) { + return unpackedDist + } // Final fallback: APP_ROOT/dist. When packaged with asar:true this lives // INSIDE app.asar — not a servable filesystem directory — so the embedded @@ -2801,6 +3081,7 @@ function resolveWebDist() { // unpackedDist above resolves). If we still land here while packaged, log it // so the cause isn't silent. const fallback = path.join(APP_ROOT, 'dist') + if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) { rememberLog( `[web-dist] dashboard frontend dir resolved to an asar-internal path that ` + @@ -2808,13 +3089,18 @@ function resolveWebDist() { `Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.` ) } + return fallback } function resolveRendererIndex() { const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')] const found = candidates.find(fileExists) - if (found) return found + + if (found) { + return found + } + // Nothing on disk. A packaged build with no renderer bundle blank-pages with // a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause // and the fix before Electron loads the missing file. @@ -2823,6 +3109,7 @@ function resolveRendererIndex() { `renderer bundle. Tried: ${candidates.join(', ')}. ` + `Rebuild with: hermes desktop --force-build` ) + return candidates[0] } @@ -2859,14 +3146,19 @@ function resolveHermesCwd() { ] for (const candidate of candidates) { - if (!candidate) continue + if (!candidate) { + continue + } + const resolved = path.resolve(String(candidate)) if (isPackagedInstallPath(resolved)) { continue } - if (directoryExists(resolved)) return resolved + if (directoryExists(resolved)) { + return resolved + } } return app.getPath('home') @@ -2934,9 +3226,12 @@ function writeDefaultProjectDir(dir) { } } -function createPythonBackend(root, label, backendArgs, options = {}) { +function createPythonBackend(root, label, backendArgs, options: any = {}) { const python = findPythonForRoot(root) - if (!python) return null + + if (!python) { + return null + } const venvRoot = path.join(root, 'venv') const venvPython = getVenvPython(venvRoot) @@ -2986,9 +3281,13 @@ function resolveHermesBackend(backendArgs) { // 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer // checkout. Honour it as-is (no bootstrap; the user is driving). const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT) + if (overrideRoot && isHermesSourceRoot(overrideRoot)) { const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, backendArgs) - if (backend) return backend + + if (backend) { + return backend + } } // 2. Development source -- when running `npm run dev` from a checkout, the @@ -2997,7 +3296,10 @@ function resolveHermesBackend(backendArgs) { // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.) if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, backendArgs) - if (backend) return backend + + if (backend) { + return backend + } } // 3. Bootstrap-complete ACTIVE_HERMES_ROOT -- the canonical install at @@ -3021,6 +3323,7 @@ function resolveHermesBackend(backendArgs) { if (hermesOverride) { const resolvedOverride = findOnPath(hermesOverride) + if (resolvedOverride) { hermesCommand = resolvedOverride } else if (!isWindowsBinaryPathInWsl(hermesOverride, { isWsl: IS_WSL })) { @@ -3041,6 +3344,7 @@ function resolveHermesBackend(backendArgs) { if (hermesCommand) { const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) + if (unwrapped) { return unwrapped } @@ -3050,9 +3354,10 @@ function resolveHermesBackend(backendArgs) { // entry-point pointing at a deleted interpreter) still resolves // via findOnPath but explodes on spawn -- the user then sees a // dead backend instead of the first-launch installer. The cheap - // `--version` probe (see backend-probes.cjs) catches that case + // `--version` probe (see backend-probes.ts) catches that case // and lets the resolver fall through to step 6 / bootstrap. const shellForProbe = isCommandScript(hermesCommand) + if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) { return ( unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || { @@ -3066,6 +3371,7 @@ function resolveHermesBackend(backendArgs) { } ) } + rememberLog( `Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.` ) @@ -3076,6 +3382,7 @@ function resolveHermesBackend(backendArgs) { // Same rationale as #4 -- the user installed this; we use it but don't // take ownership. const python = findSystemPython() + if (python) { // Same smoke-test rationale as step 4: a system Python in the // SUPPORTED_VERSIONS range can be registered (PEP 514) without @@ -3096,6 +3403,7 @@ function resolveHermesBackend(backendArgs) { shell: false } } + rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } @@ -3128,6 +3436,7 @@ function resolveHermesBackend(backendArgs) { async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) + return backend } @@ -3144,9 +3453,10 @@ async function ensureRuntime(backend) { rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap') if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) { - const handoffError = new Error( + const handoffError: Error & { isBootstrapFailure?: boolean; bootstrapHandedOff?: boolean } = new Error( 'Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.' ) + handoffError.isBootstrapFailure = true handoffError.bootstrapHandedOff = true bootstrapFailure = handoffError @@ -3188,6 +3498,7 @@ async function ensureRuntime(backend) { } catch { void 0 } + try { broadcastBootstrapEvent(ev) } catch { @@ -3200,7 +3511,7 @@ async function ensureRuntime(backend) { bootstrapAbortController = null if (bootstrapResult.cancelled) { - const cancelledError = new Error('Hermes install was cancelled.') + const cancelledError = new Error('Hermes install was cancelled.') as any cancelledError.isBootstrapFailure = true cancelledError.bootstrapCancelled = true bootstrapFailure = cancelledError @@ -3212,7 +3523,8 @@ async function ensureRuntime(backend) { `Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` + `${bootstrapResult.error || 'unknown error'}. ` + `Check ${path.join(HERMES_HOME, 'logs', 'desktop.log')} for the full transcript.` - ) + ) as any + bootstrapError.isBootstrapFailure = true bootstrapError.failedStage = bootstrapResult.failedStage || null // Latch the failure so subsequent startHermes() calls return this @@ -3223,6 +3535,7 @@ async function ensureRuntime(backend) { } rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.') + // Re-resolve now that the install exists. The new resolution lands in // step 3 (bootstrap-complete marker) and we recurse to wire venvPython. return ensureRuntime(resolveHermesBackend(backend.args)) @@ -3257,6 +3570,7 @@ async function ensureRuntime(backend) { } const venvPython = getVenvPython(VENV_ROOT) + if (!fileExists(venvPython)) { // No venv at the expected location AND no bootstrap-needed sentinel // means we have a half-installed checkout: .git exists, source files @@ -3279,10 +3593,11 @@ async function ensureRuntime(backend) { running: true, error: null }) + return backend } -function fetchJson(url, token, options = {}) { +function fetchJson(url, token, options: any = {}) { return new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) const parsed = new URL(url) @@ -3291,6 +3606,7 @@ function fetchJson(url, token, options = {}) { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + return } @@ -3310,20 +3626,26 @@ function fetchJson(url, token, options = {}) { res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8') + if ((res.statusCode || 500) >= 400) { reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) + return } + if (!text) { resolve(null) + return } + // A 2xx response whose body is HTML means the request fell through // to the SPA index.html (e.g. an unregistered /api path). JSON.parse // would throw an opaque `Unexpected token '<'` here, so surface a // clear diagnostic with the offending URL instead. const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || '') + if (looksHtml || contentType.includes('text/html')) { reject( new Error( @@ -3331,8 +3653,10 @@ function fetchJson(url, token, options = {}) { 'The endpoint is likely missing on the Hermes backend.' ) ) + return } + try { resolve(JSON.parse(text)) } catch { @@ -3346,12 +3670,16 @@ function fetchJson(url, token, options = {}) { req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - if (body) req.write(body) + + if (body) { + req.write(body) + } + req.end() }) } -function fetchPublicJson(url, options = {}) { +function fetchPublicJson(url, options: any = {}) { // Credential-free JSON GET/POST for public gateway endpoints // (``/api/status``, ``/api/auth/providers``). Unlike ``fetchJson`` it sends // NO ``X-Hermes-Session-Token`` header — used by the auth-mode probe before @@ -3360,17 +3688,21 @@ function fetchPublicJson(url, options = {}) { return new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) let parsed + try { parsed = new URL(url) } catch (error) { reject(new Error(`Invalid URL: ${error.message}`)) + return } + const client = parsed.protocol === 'https:' ? https : http const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + return } @@ -3388,16 +3720,22 @@ function fetchPublicJson(url, options = {}) { res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8') + if ((res.statusCode || 500) >= 400) { reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) + return } + if (!text) { resolve(null) + return } + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || '') + if (looksHtml || contentType.includes('text/html')) { reject( new Error( @@ -3405,8 +3743,10 @@ function fetchPublicJson(url, options = {}) { 'The endpoint is likely missing on the Hermes backend.' ) ) + return } + try { resolve(JSON.parse(text)) } catch { @@ -3420,7 +3760,11 @@ function fetchPublicJson(url, options = {}) { req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - if (body) req.write(body) + + if (body) { + req.write(body) + } + req.end() }) } @@ -3436,12 +3780,31 @@ function extensionForMimeType(mimeType) { .split(';')[0] .trim() .toLowerCase() - if (type === 'image/png') return '.png' - if (type === 'image/jpeg') return '.jpg' - if (type === 'image/gif') return '.gif' - if (type === 'image/webp') return '.webp' - if (type === 'image/bmp') return '.bmp' - if (type === 'image/svg+xml') return '.svg' + + if (type === 'image/png') { + return '.png' + } + + if (type === 'image/jpeg') { + return '.jpg' + } + + if (type === 'image/gif') { + return '.gif' + } + + if (type === 'image/webp') { + return '.webp' + } + + if (type === 'image/bmp') { + return '.bmp' + } + + if (type === 'image/svg+xml') { + return '.svg' + } + return '' } @@ -3449,6 +3812,7 @@ function filenameFromUrl(rawUrl, fallback = 'image') { try { const parsed = new URL(rawUrl) const base = path.basename(decodeURIComponent(parsed.pathname || '')) + return base && base.includes('.') ? base : fallback } catch { return fallback @@ -3462,12 +3826,15 @@ const TITLE_CACHE_LIMIT = 500 const TITLE_BYTE_BUDGET = 96 * 1024 const TITLE_TIMEOUT_MS = 5000 const TITLE_MAX_REDIRECTS = 3 + // Browser-shaped UA — many bot-walled sites (GetYourGuide, Cloudflare-protected // pages) refuse anything that doesn't look like a real Chrome. const TITLE_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36' + const TITLE_ERROR_RE = /\b(access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i + const HTML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" } // Tier-2 renderer fallback config. Only invoked when curl came back empty or @@ -3475,6 +3842,7 @@ const HTML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: const RENDER_TITLE_MAX_CONCURRENT = 2 const RENDER_TITLE_TIMEOUT_MS = 8000 const RENDER_TITLE_GRACE_MS = 700 + // Resource types we cancel before the network even fires — keeps the hidden // renderer fast and cuts third-party tracking noise. const RENDER_TITLE_BLOCKED_RESOURCES = new Set([ @@ -3494,7 +3862,10 @@ const renderTitleQueue = [] function canonicalTitleCacheKey(rawUrl) { const value = String(rawUrl || '').trim() - if (!value) return '' + + if (!value) { + return '' + } try { const url = new URL(value) @@ -3508,7 +3879,10 @@ function canonicalTitleCacheKey(rawUrl) { } function cacheTitle(key, title) { - if (titleCache.size >= TITLE_CACHE_LIMIT) titleCache.delete(titleCache.keys().next().value) + if (titleCache.size >= TITLE_CACHE_LIMIT) { + titleCache.delete(titleCache.keys().next().value) + } + titleCache.set(key, title) } @@ -3521,13 +3895,17 @@ function decodeHtmlEntities(value) { function parseHtmlTitle(html) { const raw = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] + return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : '' } -function fetchHtmlTitleWithCurl(rawUrl) { +function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> { return new Promise(resolve => { const url = String(rawUrl || '').trim() - if (!url) return resolve('') + + if (!url) { + return resolve('') + } const args = [ '--silent', @@ -3550,12 +3928,16 @@ function fetchHtmlTitleWithCurl(rawUrl) { '--raw', url ] + const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] })) const chunks = [] let bytes = 0 child.stdout.on('data', chunk => { - if (bytes >= TITLE_BYTE_BUDGET) return + if (bytes >= TITLE_BYTE_BUDGET) { + return + } + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) const remaining = TITLE_BYTE_BUDGET - bytes const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer @@ -3565,19 +3947,26 @@ function fetchHtmlTitleWithCurl(rawUrl) { child.on('error', () => resolve('')) child.on('close', () => { - if (!chunks.length) return resolve('') + if (!chunks.length) { + return resolve('') + } + resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8'))) }) }) } function getLinkTitleSession() { - if (linkTitleSession || !app.isReady()) return linkTitleSession + if (linkTitleSession || !app.isReady()) { + return linkTitleSession + } + linkTitleSession = session.fromPartition('hermes:link-titles', { cache: false }) linkTitleSession.webRequest.onBeforeRequest((details, callback) => { callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) }) }) guardLinkTitleSession(linkTitleSession) + return linkTitleSession } @@ -3595,10 +3984,15 @@ function dequeueRenderTitle() { function runRenderTitleJob(rawUrl) { return new Promise(resolve => { - if (!app.isReady()) return resolve('') + if (!app.isReady()) { + return resolve('') + } const partitionSession = getLinkTitleSession() - if (!partitionSession) return resolve('') + + if (!partitionSession) { + return resolve('') + } let settled = false let window = null @@ -3606,16 +4000,30 @@ function runRenderTitleJob(rawUrl) { let graceTimer = null const finish = title => { - if (settled) return + if (settled) { + return + } + settled = true - if (hardTimer) clearTimeout(hardTimer) - if (graceTimer) clearTimeout(graceTimer) + + if (hardTimer) { + clearTimeout(hardTimer) + } + + if (graceTimer) { + clearTimeout(graceTimer) + } + const value = (title || '').replace(/\s+/g, ' ').trim() + try { - if (window && !window.isDestroyed()) window.destroy() + if (window && !window.isDestroyed()) { + window.destroy() + } } catch { // BrowserWindow may already be torn down; ignore. } + resolve(value) } @@ -3626,8 +4034,12 @@ function runRenderTitleJob(rawUrl) { } const finishWithTitle = () => finish(readLinkTitleWindowTitle(window)) + const scheduleGrace = () => { - if (graceTimer) clearTimeout(graceTimer) + if (graceTimer) { + clearTimeout(graceTimer) + } + graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS) } @@ -3637,7 +4049,9 @@ function runRenderTitleJob(rawUrl) { window.webContents.on('page-title-updated', scheduleGrace) window.webContents.on('did-finish-load', scheduleGrace) window.webContents.on('did-fail-load', (_event, _code, _desc, _validatedURL, isMainFrame) => { - if (isMainFrame) finish('') + if (isMainFrame) { + finish('') + } }) window @@ -3649,7 +4063,7 @@ function runRenderTitleJob(rawUrl) { }) } -function fetchHtmlTitleWithRenderer(rawUrl) { +function fetchHtmlTitleWithRenderer(rawUrl: string): Promise<string> { return new Promise(resolve => { renderTitleQueue.push({ resolve, url: rawUrl }) dequeueRenderTitle() @@ -3658,14 +4072,25 @@ function fetchHtmlTitleWithRenderer(rawUrl) { // Strips known error/captcha titles (e.g. "GetYourGuide – Error", "Just a // moment...") so they don't get cached as the resolved title. -const usableTitle = value => (value && !TITLE_ERROR_RE.test(value) ? value : '') +function usableTitle(value: string): string { + return value && !TITLE_ERROR_RE.test(value) ? value : '' +} function fetchLinkTitle(rawUrl) { const url = String(rawUrl || '').trim() const key = canonicalTitleCacheKey(url) - if (!key) return Promise.resolve('') - if (titleCache.has(key)) return Promise.resolve(titleCache.get(key)) - if (titleInflight.has(key)) return titleInflight.get(key) + + if (!key) { + return Promise.resolve('') + } + + if (titleCache.has(key)) { + return Promise.resolve(titleCache.get(key)) + } + + if (titleInflight.has(key)) { + return titleInflight.get(key) + } const pending = fetchHtmlTitleWithCurl(url) .catch(() => '') @@ -3676,38 +4101,53 @@ function fetchLinkTitle(rawUrl) { .then(clean => { cacheTitle(key, clean) titleInflight.delete(key) + return clean }) titleInflight.set(key, pending) + return pending } async function resourceBufferFromUrl(rawUrl) { - if (!rawUrl) throw new Error('Missing URL') + if (!rawUrl) { + throw new Error('Missing URL') + } + if (rawUrl.startsWith('data:')) { const match = rawUrl.match(/^data:([^;,]+)?(;base64)?,(.*)$/s) - if (!match) throw new Error('Invalid data URL') + + if (!match) { + throw new Error('Invalid data URL') + } + const mimeType = match[1] || 'application/octet-stream' const encoded = match[3] || '' const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8') + return { buffer, mimeType } } + if (/^file:/i.test(rawUrl)) { const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' }) const buffer = await fs.promises.readFile(resolvedPath) + return { buffer, mimeType: mimeTypeForPath(resolvedPath) } } const parsed = new URL(rawUrl) const client = parsed.protocol === 'https:' ? https : http + return new Promise((resolve, reject) => { const req = client.get(parsed, res => { if ((res.statusCode || 500) >= 400) { reject(new Error(`Failed to fetch ${rawUrl}: ${res.statusCode}`)) res.resume() + return } + const chunks = [] res.on('error', reject) res.on('data', chunk => chunks.push(chunk)) @@ -3718,26 +4158,37 @@ async function resourceBufferFromUrl(rawUrl) { }) }) }) + req.on('error', reject) }) } async function copyImageFromUrl(rawUrl) { - const { buffer } = await resourceBufferFromUrl(rawUrl) + const { buffer } = (await resourceBufferFromUrl(rawUrl)) as any const image = nativeImage.createFromBuffer(buffer) - if (image.isEmpty()) throw new Error('Could not read image') + + if (image.isEmpty()) { + throw new Error('Could not read image') + } + clipboard.writeImage(image) } async function saveImageFromUrl(rawUrl) { - const { buffer, mimeType } = await resourceBufferFromUrl(rawUrl) + const { buffer, mimeType } = (await resourceBufferFromUrl(rawUrl)) as any const fallbackName = filenameFromUrl(rawUrl, `image${extensionForMimeType(mimeType) || '.png'}`) + const result = await dialog.showSaveDialog(mainWindow, { title: 'Save Image', defaultPath: fallbackName }) - if (result.canceled || !result.filePath) return false + + if (result.canceled || !result.filePath) { + return false + } + await fs.promises.writeFile(result.filePath, buffer) + return true } @@ -3745,6 +4196,7 @@ async function writeComposerImage(buffer, ext = '.png') { const rawExt = String(ext || '.png') .trim() .toLowerCase() + const normalizedExt = rawExt.startsWith('.') ? rawExt : `.${rawExt}` const safeExt = /^\.[a-z0-9]{1,5}$/.test(normalizedExt) ? normalizedExt : '.png' const dir = path.join(app.getPath('userData'), 'composer-images') @@ -3753,6 +4205,7 @@ async function writeComposerImage(buffer, ext = '.png') { const random = crypto.randomBytes(3).toString('hex') const filePath = path.join(dir, `composer_${stamp}_${random}${safeExt}`) await fs.promises.writeFile(filePath, buffer) + return filePath } @@ -3777,6 +4230,7 @@ function expandUserPath(filePath) { async function previewFileTarget(rawTarget, baseDir) { const raw = String(rawTarget || '').trim() const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd() + let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), { baseDir: base, purpose: 'Preview target' @@ -3787,6 +4241,7 @@ async function previewFileTarget(rawTarget, baseDir) { } const ext = path.extname(resolved).toLowerCase() + if (!fileExists(resolved)) { return null } @@ -3858,13 +4313,21 @@ async function normalizePreviewTarget(rawTarget, baseDir) { async function filePathFromPreviewUrl(rawUrl) { const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' }) + return resolvedPath } function sendPreviewFileChanged(payload) { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:preview-file-changed', payload) } @@ -3874,6 +4337,7 @@ async function watchPreviewFile(rawUrl) { const targetName = path.basename(filePath) const id = crypto.randomBytes(12).toString('base64url') let timer = null + const watcher = fs.watch(watchDir, (_eventType, filename) => { const changedName = filename ? path.basename(String(filename)) : '' @@ -3881,17 +4345,27 @@ async function watchPreviewFile(rawUrl) { return } - if (timer) clearTimeout(timer) + if (timer) { + clearTimeout(timer) + } + timer = setTimeout(() => { timer = null - if (!fileExists(filePath)) return + + if (!fileExists(filePath)) { + return + } + sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() }) }, PREVIEW_WATCH_DEBOUNCE_MS) }) previewWatchers.set(id, { close: () => { - if (timer) clearTimeout(timer) + if (timer) { + clearTimeout(timer) + } + watcher.close() } }) @@ -3925,6 +4399,7 @@ async function waitForHermes(baseUrl, token) { while (Date.now() < deadline) { try { await fetchJson(`${baseUrl}/api/status`, token) + return } catch (error) { lastError = error @@ -3936,7 +4411,10 @@ async function waitForHermes(baseUrl, token) { } function getWindowButtonPosition() { - if (!IS_MAC) return null + if (!IS_MAC) { + return null + } + return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION } @@ -3953,16 +4431,36 @@ function getWindowState() { } function sendBackendExit(payload) { - if (!mainWindow || mainWindow.isDestroyed()) return + // Intentional soft re-home (gateway mode apply) kills the child on purpose — + // don't surface the "backend stopped" error toast / boot-failure path. + if (softRehomeInProgress) { + return + } + + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:backend-exit', payload) } function sendClosePreviewRequested() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:close-preview-requested') } @@ -3970,17 +4468,28 @@ function sendClosePreviewRequested() { // renderer's WebSocket to the local backend; the renderer reconnects on this // signal so the chat composer doesn't stay stuck on "Starting Hermes...". function sendPowerResume() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:power-resume') } let powerResumeRegistered = false function registerPowerResumeListeners() { - if (powerResumeRegistered) return + if (powerResumeRegistered) { + return + } + powerResumeRegistered = true + try { // 'resume' covers sleep/wake; 'unlock-screen' covers lock/unlock without a // full suspend. Either can drop an idle socket. @@ -3997,18 +4506,36 @@ function getAppIconPath() { } function sendOpenUpdatesRequested() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:open-updates') - if (!mainWindow.isVisible()) mainWindow.show() + + if (!mainWindow.isVisible()) { + mainWindow.show() + } + mainWindow.focus() } -function sendWindowStateChanged(nextIsFullscreen) { - if (!mainWindow || mainWindow.isDestroyed()) return +function sendWindowStateChanged(nextIsFullscreen?: boolean) { + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + const state = getWindowState() if (typeof nextIsFullscreen === 'boolean') { @@ -4020,10 +4547,12 @@ function sendWindowStateChanged(nextIsFullscreen) { function buildApplicationMenu() { const template = [] + const checkForUpdatesItem = { label: 'Check for Updates…', click: () => sendOpenUpdatesRequested() } + if (IS_MAC) { template.push({ label: APP_NAME, @@ -4130,6 +4659,7 @@ function toggleDevTools(window) { // increase versus a much better support story when WS connection or // CSP issues surface in the field. const { webContents } = window + if (webContents.isDevToolsOpened()) { webContents.closeDevTools() } else { @@ -4141,11 +4671,16 @@ function installDevToolsShortcut(window) { // F12 / Cmd+Opt+I works in both dev and packaged builds. window.webContents.on('before-input-event', (event, input) => { const key = input.key.toLowerCase() + const isInspectShortcut = input.key === 'F12' || (IS_MAC && input.meta && input.alt && key === 'i') || (!IS_MAC && input.control && input.shift && key === 'i') - if (!isInspectShortcut) return + + if (!isInspectShortcut) { + return + } + event.preventDefault() toggleDevTools(window) }) @@ -4156,7 +4691,9 @@ function installPreviewShortcut(window) { const key = String(input.key || '').toLowerCase() const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift - if (!isPreviewCloseShortcut || !previewShortcutActive) return + if (!isPreviewCloseShortcut || !previewShortcutActive) { + return + } event.preventDefault() sendClosePreviewRequested() @@ -4167,15 +4704,23 @@ function installPreviewShortcut(window) { // survives reloads/restarts) rather than a main-process JSON file. The main // process owns setZoomLevel, so we mirror each change into localStorage and // read it back on did-finish-load to re-apply after reloads or crash recovery. -const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs') +import { + applyZoomLevel, + installZoomReassertOnWindowEvents, + percentToZoomLevel, + ZOOM_STORAGE_KEY, + zoomLevelToPercent, + zoomWiringForWindowKind +} from './zoom' function setAndPersistZoomLevel(window, zoomLevel) { - if (!window || window.isDestroyed()) return - const next = clampZoomLevel(zoomLevel) - window.webContents.setZoomLevel(next) - // Keep any open settings UI in sync, including changes made via the - // keyboard shortcuts or the View menu. - window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) }) + if (!window || window.isDestroyed()) { + return + } + + // Apply + notify in one funnel so the settings UI stays in sync, including + // changes made via the keyboard shortcuts or the View menu. + const next = applyZoomLevel(window.webContents, zoomLevel) window.webContents .executeJavaScript( `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}` @@ -4184,15 +4729,22 @@ function setAndPersistZoomLevel(window, zoomLevel) { } function restorePersistedZoomLevel(window) { - if (!window || window.isDestroyed()) return + if (!window || window.isDestroyed()) { + return + } + window.webContents .executeJavaScript( `(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()` ) .then(stored => { - if (stored == null || !window || window.isDestroyed()) return - const level = clampZoomLevel(Number(stored)) - window.webContents.setZoomLevel(level) + if (stored == null || !window || window.isDestroyed()) { + return + } + + // Notify the renderer too — otherwise the Appearance UI Scale control + // can stay stuck at 100% even though the window zoom was restored. + applyZoomLevel(window.webContents, Number(stored)) }) .catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`)) } @@ -4205,9 +4757,13 @@ function installZoomShortcuts(window) { const ZOOM_STEP = 0.1 window.webContents.on('before-input-event', (event, input) => { const mod = IS_MAC ? input.meta : input.control - if (!mod || input.alt || input.shift) return + + if (!mod || input.alt || input.shift) { + return + } const key = input.key + if (key === '0') { event.preventDefault() setAndPersistZoomLevel(window, 0) @@ -4260,7 +4816,10 @@ function installContextMenu(window) { } if (hasLink) { - if (template.length) template.push({ type: 'separator' }) + if (template.length) { + template.push({ type: 'separator' }) + } + template.push( { label: 'Open Link', @@ -4279,7 +4838,9 @@ function installContextMenu(window) { const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : [] if (isEditable && params.misspelledWord && suggestions.length > 0) { - if (template.length) template.push({ type: 'separator' }) + if (template.length) { + template.push({ type: 'separator' }) + } for (const suggestion of suggestions.slice(0, 5)) { template.push({ @@ -4296,7 +4857,10 @@ function installContextMenu(window) { } if (hasSelection || isEditable) { - if (template.length) template.push({ type: 'separator' }) + if (template.length) { + template.push({ type: 'separator' }) + } + if (isEditable) { template.push( { role: 'cut', enabled: params.editFlags.canCut }, @@ -4332,15 +4896,19 @@ function isAudioCapturePermission(permission, details) { if (permission === 'audioCapture') { return true } + if (permission !== 'media') { return false } + const mediaTypes = details?.mediaTypes + if (!Array.isArray(mediaTypes) || mediaTypes.length === 0) { // Windows: mediaTypes is often empty for a mic request. Don't deny on // missing metadata. (A video request would carry mediaTypes:['video'].) return true } + return mediaTypes.includes('audio') && !mediaTypes.includes('video') } @@ -4355,9 +4923,10 @@ function installMediaPermissions() { // the check defaults to false and the mic is denied before the request // handler ever runs. session.defaultSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => { - if (permission === 'media' || permission === 'audioCapture') { + if (permission === 'media' || permission === ('audioCapture' as any) /* todo: is this needed? */) { // details.mediaType is a single string here (not the mediaTypes array). const mediaType = details?.mediaType + if (mediaType === 'video') { return false } @@ -4401,27 +4970,38 @@ function installMediaPermissions() { const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth' function getOauthSession() { - if (oauthSession || !app.isReady()) return oauthSession + if (oauthSession || !app.isReady()) { + return oauthSession + } + oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION) + return oauthSession } // Bare + prefixed variants of the session cookies live in -// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See +// connection-config.ts (cookiesHaveSession / cookiesHaveLiveSession). See // that module for details. async function hasOauthSessionCookie(baseUrl) { const sess = getOauthSession() - if (!sess) return false + + if (!sess) { + return false + } + const parsed = new URL(baseUrl) + try { // Query by URL so the cookie jar applies Domain/Path/Secure scoping for us. const cookies = await sess.cookies.get({ url: baseUrl }) + return cookiesHaveSession(cookies) } catch { // Fall back to a host match if the URL query path errors. try { const cookies = await sess.cookies.get({ domain: parsed.hostname }) + return cookiesHaveSession(cookies) } catch { return false @@ -4438,14 +5018,21 @@ async function hasOauthSessionCookie(baseUrl) { // cheap early-out before attempting a network round-trip in resolveRemoteBackend. async function hasLiveOauthSession(baseUrl) { const sess = getOauthSession() - if (!sess) return false + + if (!sess) { + return false + } + const parsed = new URL(baseUrl) + try { const cookies = await sess.cookies.get({ url: baseUrl }) + return cookiesHaveLiveSession(cookies) } catch { try { const cookies = await sess.cookies.get({ domain: parsed.hostname }) + return cookiesHaveLiveSession(cookies) } catch { return false @@ -4455,13 +5042,18 @@ async function hasLiveOauthSession(baseUrl) { async function clearOauthSession(baseUrl) { const sess = getOauthSession() - if (!sess) return + + if (!sess) { + return + } + try { const cookies = await sess.cookies.get(baseUrl ? { url: baseUrl } : {}) await Promise.all( cookies.map(c => { const scheme = c.secure ? 'https' : 'http' const cookieUrl = `${scheme}://${c.domain.replace(/^\./, '')}${c.path || '/'}` + return sess.cookies.remove(cookieUrl, c.name).catch(() => undefined) }) ) @@ -4470,51 +5062,97 @@ async function clearOauthSession(baseUrl) { } } -// Open the gateway's /login page in a visible window using the OAuth session -// partition, and resolve once the access-token cookie appears (login done) or -// reject if the user closes the window first. The window navigates through the -// IDP and back to /auth/callback, which sets the session cookies on the -// partition; we poll the cookie jar rather than try to read the HttpOnly value. -function openOauthLoginWindow(baseUrl) { +// Open a gateway login window in the OAuth session partition, resolving once +// the access-token cookie appears (login done) or rejecting if the user closes +// the window first. The window navigates through the IDP and back to +// /auth/callback, which sets the session cookies on the partition; we poll the +// cookie jar rather than try to read the HttpOnly value. +// +// `silent` selects the URL the window loads, which decides interactive-vs-silent: +// - silent=false (default): load ``/login`` — the public interstitial that +// renders the "Log in with X" provider chooser. This is the interactive +// remote-gateway login the settings UI drives. +// - silent=true: load the PROTECTED root ``/`` instead. ``/login`` is a public +// route, so loading it NEVER triggers the gate's auto-SSO and always shows +// the chooser. Loading a protected page with no session cookie makes the +// gate run ``_auto_sso_response``: single registered provider + a live +// portal session in this partition → a silent 302 through +// ``/auth/login`` → portal ``/oauth/authorize`` (auto-approves org members) +// → ``/auth/callback``, which sets the gateway cookie with NO interactive +// prompt. This is the per-agent cloud cascade (decisions.md Q5). +function openOauthLoginWindow(baseUrl, { silent = false } = {}) { return new Promise((resolve, reject) => { if (!app.isReady()) { reject(new Error('Desktop is not ready to start an OAuth login.')) + return } + const sess = getOauthSession() + if (!sess) { reject(new Error('OAuth session partition is unavailable.')) + return } let settled = false let win = null let pollTimer = null + let revealTimer = null const finish = err => { - if (settled) return + if (settled) { + return + } + settled = true - if (pollTimer) clearInterval(pollTimer) + + if (pollTimer) { + clearInterval(pollTimer) + } + + if (revealTimer) { + clearTimeout(revealTimer) + } + try { - if (win && !win.isDestroyed()) win.destroy() + if (win && !win.isDestroyed()) { + win.destroy() + } } catch { // window already torn down } - if (err) reject(err) - else resolve({ baseUrl, ok: true }) + + if (err) { + reject(err) + } else { + resolve({ baseUrl, ok: true }) + } } const checkCookie = async () => { - if (settled) return - if (await hasOauthSessionCookie(baseUrl)) finish(null) + if (settled) { + return + } + + if (await hasOauthSessionCookie(baseUrl)) { + finish(null) + } } try { win = new BrowserWindow({ width: 520, height: 720, - title: 'Sign in to Hermes gateway', + title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway', autoHideMenuBar: true, + // Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in + // well under a second, so the window normally never needs to show. We + // only reveal it as a fallback if the cascade DOESN'T complete quickly + // (e.g. the portal session lapsed and the gate fell through to the + // interactive chooser) — see the reveal timer below. + show: !silent, webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -4525,6 +5163,7 @@ function openOauthLoginWindow(baseUrl) { }) } catch (error) { finish(error instanceof Error ? error : new Error(String(error))) + return } @@ -4536,14 +5175,37 @@ function openOauthLoginWindow(baseUrl) { win.webContents.on('did-frame-navigate', () => void checkCookie()) pollTimer = setInterval(() => void checkCookie(), 750) + // Silent-mode reveal fallback: if the cascade hasn't settled shortly, the + // auto-SSO didn't go through silently (no portal session, multi-provider, + // loop-guard tripped, etc.) and the window is now showing an interactive + // page. Reveal it so the user can complete sign-in manually rather than + // staring at nothing. Cleared on finish(). + if (silent && win) { + revealTimer = setTimeout(() => { + try { + if (!settled && win && !win.isDestroyed() && !win.isVisible()) { + win.show() + } + } catch { + // window torn down + } + }, 2500) + } + win.on('closed', () => { - if (!settled) finish(new Error('Login window closed before authentication completed.')) + if (!settled) { + finish(new Error('Login window closed before authentication completed.')) + } }) // ``next`` is intentionally omitted: the gateway lands on ``/`` after // login, which is a valid authenticated page that sets the cookies. We // only care that the cookie jar is populated. - const loginUrl = `${normalizeRemoteBaseUrl(baseUrl)}/login` + // + // silent=true loads the protected root so the gate auto-SSOs (no chooser); + // silent=false loads the public ``/login`` chooser for interactive sign-in. + const normalizedBase = normalizeRemoteBaseUrl(baseUrl) + const loginUrl = silent ? `${normalizedBase}/` : `${normalizedBase}/login` win.loadURL(loginUrl).catch(error => { finish(error instanceof Error ? error : new Error(String(error))) }) @@ -4553,24 +5215,32 @@ function openOauthLoginWindow(baseUrl) { // JSON request routed through the OAuth session partition so the HttpOnly // session cookie is attached automatically by Electron's net stack. Used for // authed REST against a gated gateway, including minting WS tickets. -function fetchJsonViaOauthSession(url, options = {}) { +function fetchJsonViaOauthSession(url, options: any = {}) { return new Promise((resolve, reject) => { const sess = getOauthSession() + if (!sess) { reject(new Error('OAuth session partition is unavailable.')) + return } + let parsed + try { parsed = new URL(url) } catch (error) { reject(new Error(`Invalid URL: ${error.message}`)) + return } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + return } + const body = serializeJsonBody(options.body) const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) @@ -4580,17 +5250,21 @@ function fetchJsonViaOauthSession(url, options = {}) { session: sess, useSessionCookies: true, redirect: 'follow' - }) + } as any) + setJsonRequestHeaders(request) let timedOut = false + const timer = setTimeout(() => { timedOut = true + try { request.abort() } catch { // already finished } + reject(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }, timeoutMs) @@ -4598,26 +5272,37 @@ function fetchJsonViaOauthSession(url, options = {}) { const chunks = [] res.on('data', chunk => chunks.push(Buffer.from(chunk))) res.on('end', () => { - if (timedOut) return + if (timedOut) { + return + } + clearTimeout(timer) const text = Buffer.concat(chunks).toString('utf8') const statusCode = res.statusCode || 500 + if (statusCode >= 400) { - const err = new Error(`${statusCode}: ${text || ''}`) + const err = new Error(`${statusCode}: ${text || ''}`) as any err.statusCode = statusCode reject(err) + return } + if (!text) { resolve(null) + return } + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || res.headers['Content-Type'] || '') + if (looksHtml || contentType.includes('text/html')) { reject(new Error(`Expected JSON from ${url} but got HTML (status ${statusCode}).`)) + return } + try { resolve(JSON.parse(text)) } catch { @@ -4626,11 +5311,18 @@ function fetchJsonViaOauthSession(url, options = {}) { }) }) request.on('error', error => { - if (timedOut) return + if (timedOut) { + return + } + clearTimeout(timer) reject(error) }) - if (body) request.write(body) + + if (body) { + request.write(body) + } + request.end() }) } @@ -4639,14 +5331,17 @@ function fetchJsonViaOauthSession(url, options = {}) { // Throws (with statusCode 401) if the session cookie is missing/expired — // callers treat that as "needs re-login". async function mintGatewayWsTicket(baseUrl) { - const body = await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { + const body = (await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { method: 'POST', timeoutMs: 8_000 - }) + })) as any + const ticket = body?.ticket + if (!ticket || typeof ticket !== 'string') { throw new Error('Gateway did not return a WS ticket.') } + return ticket } @@ -4664,14 +5359,321 @@ async function freshGatewayWsUrl(profile) { // the wrong profile's DB. A null/empty profile resolves to the primary, so // legacy callers and single-profile users are unchanged. const connection = await ensureBackend(profile) + if (connection.authMode === 'oauth') { const ticket = await mintGatewayWsTicket(connection.baseUrl) + return buildGatewayWsUrlWithTicket(connection.baseUrl, ticket) } + // Local/token: the cached wsUrl already carries the (long-lived) token. return connection.wsUrl } +// --- Hermes Cloud discovery + silent per-agent sign-in (cloud-auto-discovery +// Phase 3) --------------------------------------------------------------- +// +// The "cloud" connection mode lets a user sign in to the Nous portal ONCE in +// the OAuth session partition, then (a) discover their hosted agents and (b) +// connect to any of them with no second interactive sign-in. Both ride the one +// portal session cookie living in `persist:hermes-remote-oauth`: +// - discovery → GET {portal}/api/agents over the partition-bound net; the +// portal session cookie authenticates it (NAS Phase 2.5 accepts the cookie). +// - cascade → opening an agent's own /login in the same partition hits the +// portal's silent auto-approve (org member, existing session) and 302s back +// with that agent's session cookie — no prompt. Each agent still completes +// its own PKCE exchange; SSO removes the human click, not a security check. + +// Canonical Nous portal base URL, overridable for staging/dev. Mirrors the CLI +// convention (hermes_cli/auth.py DEFAULT_NOUS_PORTAL_URL + the same env names) +// so a single override flips every Hermes surface to the same portal. +const DEFAULT_NOUS_PORTAL_URL = 'https://portal.nousresearch.com' + +function resolvePortalBaseUrl() { + const raw = process.env.HERMES_PORTAL_BASE_URL || process.env.NOUS_PORTAL_BASE_URL || DEFAULT_NOUS_PORTAL_URL + + return String(raw).trim().replace(/\/+$/, '') +} + +// Whether the OAuth partition currently holds a live Nous portal session — the +// credential that powers both discovery and the silent cascade. The portal +// authenticates via PRIVY, not the Hermes gateway session cookies, so this +// checks for the `privy-token` cookie on the portal host (NOT +// hasLiveOauthSession, which looks for hermes_session_at/rt that the portal +// never sets). See connection-config.ts cookiesHavePrivySession. +async function hasLivePortalSession() { + const sess = getOauthSession() + + if (!sess) { + return false + } + + const portalBaseUrl = resolvePortalBaseUrl() + const parsed = new URL(portalBaseUrl) + + try { + const cookies = await sess.cookies.get({ url: portalBaseUrl }) + + return cookiesHavePrivySession(cookies) + } catch { + try { + const cookies = await sess.cookies.get({ domain: parsed.hostname }) + + return cookiesHavePrivySession(cookies) + } catch { + return false + } + } +} + +// Drive a one-time interactive portal sign-in in the OAuth partition. Unlike +// openOauthLoginWindow (which targets a gateway's /login), this lands on the +// portal itself so the resulting session cookie is portal-scoped — the cookie +// that authenticates discovery AND is reused for every silent per-agent +// cascade. Resolves once the portal session cookie appears. +function openPortalLoginWindow() { + const portalBaseUrl = resolvePortalBaseUrl() + + return new Promise((resolve, reject) => { + if (!app.isReady()) { + reject(new Error('Desktop is not ready to start a Hermes Cloud sign-in.')) + + return + } + + const sess = getOauthSession() + + if (!sess) { + reject(new Error('OAuth session partition is unavailable.')) + + return + } + + let settled = false + let win = null + let pollTimer = null + + const finish = err => { + if (settled) { + return + } + settled = true + + if (pollTimer) { + clearInterval(pollTimer) + } + + try { + if (win && !win.isDestroyed()) { + win.destroy() + } + } catch { + // window already torn down + } + + if (err) { + reject(err) + } else { + resolve({ portalBaseUrl, ok: true }) + } + } + + const checkCookie = async () => { + if (settled) { + return + } + + // A live portal (Privy) session cookie means sign-in completed. + if (await hasLivePortalSession()) { + finish(null) + } + } + + try { + win = new BrowserWindow({ + width: 520, + height: 720, + title: 'Sign in to Hermes Cloud', + autoHideMenuBar: true, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + session: sess, + webSecurity: true + } + }) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + + return + } + + win.webContents.on('did-navigate', () => void checkCookie()) + win.webContents.on('did-redirect-navigation', () => void checkCookie()) + win.webContents.on('did-frame-navigate', () => void checkCookie()) + pollTimer = setInterval(() => void checkCookie(), 750) + + win.on('closed', () => { + if (!settled) { + finish(new Error('Sign-in window closed before authentication completed.')) + } + }) + + // Land on the portal root; any authenticated portal page sets the session + // cookie. We only care that the partition cookie jar is populated. + win.loadURL(portalBaseUrl).catch(error => { + finish(error instanceof Error ? error : new Error(String(error))) + }) + }) +} + +// Discover the hosted (Hermes Cloud) agents the signed-in user can see. Calls +// the NAS trimmed-summary endpoint over the partition-bound net, so the portal +// session cookie is attached automatically (no bearer needed — NAS accepts the +// cookie). Returns { agents } on success, or { needsOrgSelection: true, orgs } +// when the user belongs to multiple orgs and hasn't picked one yet (NAS 409 +// org_selection_required). Pass `org` (a slug/id from a prior org list) to +// scope discovery to that org. Throws a needsCloudLogin-tagged error when no +// portal session is present. +async function discoverCloudAgents(org?: string) { + const portalBaseUrl = resolvePortalBaseUrl() + + if (!(await hasLivePortalSession())) { + const err = new Error( + 'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.' + ) as any + err.needsCloudLogin = true + throw err + } + + const orgQuery = org ? `?org=${encodeURIComponent(org)}` : '' + let body + + try { + body = (await fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents${orgQuery}`, { + method: 'GET', + timeoutMs: 15_000 + })) as any + } catch (error) { + // A 401 means the portal session lapsed between the liveness check and the + // call — surface it as a re-login, not a generic failure. + if (error && error.statusCode === 401) { + const err = new Error('Your Hermes Cloud session has expired. Open Settings → Gateway and sign in again.') as any + err.needsCloudLogin = true + err.cause = error + throw err + } + + // A 409 means we're a multi-org user who hasn't picked an org. The body + // carries the user's org list; surface it so the renderer shows a picker + // and re-calls discovery with the chosen org. (fetchJsonViaOauthSession + // throws on >=400 with err.statusCode + err.message "409: <json body>".) + if (error && error.statusCode === 409) { + const orgs = parseOrgSelectionError(error) + + if (orgs) { + return { needsOrgSelection: true, orgs } + } + } + + throw error + } + + return { agents: trimCloudAgents(body), org: trimCloudOrg(body?.org) } +} + +// Project a NAS response org ({ id, slug, name, isPersonal }) to the trimmed +// shape the renderer persists, or null when absent/malformed. +function trimCloudOrg(org) { + if (!org || typeof org !== 'object' || typeof org.id !== 'string') { + return null + } + + return { + id: org.id, + slug: typeof org.slug === 'string' ? org.slug : null, + name: typeof org.name === 'string' ? org.name : org.id, + isPersonal: Boolean(org.isPersonal), + role: typeof org.role === 'string' ? org.role : 'MEMBER' + } +} + +// Extract the org list from a 409 org_selection_required error body. The error +// message is "409: <raw json>" (see fetchJsonViaOauthSession); parse defensively +// and return null if it isn't the shape we expect (caller then rethrows). +function parseOrgSelectionError(error) { + const msg = String(error?.message || '') + const jsonStart = msg.indexOf('{') + + if (jsonStart < 0) { + return null + } + + let parsed + + try { + parsed = JSON.parse(msg.slice(jsonStart)) + } catch { + return null + } + + if (parsed?.error !== 'org_selection_required' || !Array.isArray(parsed.orgs)) { + return null + } + + return parsed.orgs + .filter(o => o && typeof o === 'object' && typeof o.id === 'string') + .map(o => ({ + id: o.id, + slug: typeof o.slug === 'string' ? o.slug : null, + name: typeof o.name === 'string' ? o.name : o.id, + isPersonal: Boolean(o.isPersonal), + role: typeof o.role === 'string' ? o.role : 'MEMBER' + })) +} + +// Project NAS's agent rows to the trimmed DTO the renderer consumes. +function trimCloudAgents(body) { + const agents = Array.isArray(body?.agents) ? body.agents : [] + + return agents + .filter(a => a && typeof a === 'object' && typeof a.id === 'string') + .map(a => ({ + id: a.id, + name: typeof a.name === 'string' ? a.name : a.id, + status: typeof a.status === 'string' ? a.status : 'unknown', + dashboardUrl: typeof a.dashboardUrl === 'string' ? a.dashboardUrl : null, + dashboardGatewayState: typeof a.dashboardGatewayState === 'string' ? a.dashboardGatewayState : 'unknown' + })) +} + +// Silent per-agent sign-in: open the selected agent dashboard's /login in the +// SAME OAuth partition. Because the user already holds a live portal session +// there, the agent's /oauth/authorize auto-approves (org member) and 302s back, +// setting that agent's gateway session cookie WITHOUT a second interactive +// prompt. Reuses openOauthLoginWindow — the window self-closes the instant the +// agent's session cookie lands (a silent flow finishes in well under a second; +// if the portal session were absent it would fall through to an interactive +// login, which the discovery gate already prevents). Returns once the agent's +// gateway session cookie is present. +async function cloudAgentSilentSignIn(dashboardUrl) { + const baseUrl = normalizeRemoteBaseUrl(dashboardUrl) + + // Pre-req: a live portal session must exist, or this would surface an + // interactive prompt rather than a silent cascade. Discovery already gates on + // this, but a selection can arrive after the session lapsed. + if (!(await hasLivePortalSession())) { + const err = new Error('Your Hermes Cloud session has expired. Sign in to Hermes Cloud again.') as any + err.needsCloudLogin = true + throw err + } + + await openOauthLoginWindow(baseUrl, { silent: true }) + + return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) } +} + function encryptDesktopSecret(value) { return encryptDesktopSecretStrict(value, safeStorage) } @@ -4701,29 +5703,54 @@ function decryptDesktopSecret(secret) { // Validate + normalize the per-profile remote overrides map read from disk. // Drops malformed names/entries and keeps only the recognized fields so a // hand-edited or stale connection.json can't inject junk into resolution. -function sanitizeConnectionProfiles(raw) { +function sanitizeConnectionProfiles(raw: Record<string, any>) { if (!raw || typeof raw !== 'object') { return {} } const out = {} + for (const [name, entry] of Object.entries(raw)) { if (!entry || typeof entry !== 'object') { continue } + if (name !== 'default' && !PROFILE_NAME_RE.test(name)) { continue } - const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' } + const cleaned: { + mode: 'remote' | 'local' | 'cloud' + url?: string + authMode?: string + token?: object + org?: string + } = { + mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local' + } + const url = String(entry.url || '').trim() + if (url) { cleaned.url = url } + cleaned.authMode = normAuthMode(entry.authMode) - if (entry.token && typeof entry.token === 'object') { + + if ((entry as any).token && typeof entry.token === 'object') { cleaned.token = entry.token } + + // Preserve the Hermes Cloud org tag on cloud-mode entries so Settings can + // reopen into the same org for a per-profile cloud connection. + if (cleaned.mode === 'cloud') { + const org = String(entry.org || '').trim() + + if (org) { + cleaned.org = org + } + } + out[name] = cleaned } @@ -4735,6 +5762,7 @@ function readDesktopConnectionConfig() { // process or an external tool). Our own writes update the cache inline // via writeDesktopConnectionConfig, but external changes would be missed. let mtime = null + try { mtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs } catch { @@ -4758,7 +5786,7 @@ function readDesktopConnectionConfig() { // backward compatibility with configs written before OAuth support. remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' config = { - mode: parsed.mode === 'remote' ? 'remote' : 'local', + mode: modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local', remote, // Per-profile remote overrides: each profile may point at its own // backend (local spawn or its own remote URL). Preserved verbatim so @@ -4829,9 +5857,14 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const remoteToken = decryptDesktopSecret(block.token) const authMode = normAuthMode(block.authMode) const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '') - const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local' + // The env override forces a plain remote connection. Otherwise reflect the + // saved mode, preserving 'cloud' (a Hermes Cloud connection — Q6) so the UI + // reopens into the cloud picker; any non-remote-like value collapses to local. + const savedMode = key ? scoped?.mode : config.mode + const mode = envOverride ? 'remote' : modeIsRemoteLike(savedMode) ? savedMode : 'local' let remoteOauthConnected = false + if (authMode === 'oauth' && remoteUrl) { try { // Display signal: treat a live RT cookie as "connected" even if the AT @@ -4851,6 +5884,9 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon remoteAuthMode: authMode, remoteOauthConnected, remoteUrl, + // The persisted Hermes Cloud org (slug/id) for a cloud connection, or '' for + // remote/local. Lets Settings → Gateway reopen into the same org. + cloudOrg: mode === 'cloud' ? String(block.org || '') : '', remoteTokenPreview: tokenPreview(remoteToken), remoteTokenSet: Boolean(remoteToken), // The env override only forces the global/primary connection; a per-profile @@ -4862,24 +5898,57 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon // Build + validate a `{ url, authMode, token }` remote block. OAuth gateways // authenticate via the login-window session cookie (verified at connect time in // resolveRemoteBackend), so only token-auth remotes require a saved token. -function buildRemoteBlock(remoteUrl, authMode, token) { +// `org` (optional) is the Hermes Cloud org slug/id the instance was discovered +// under — persisted so Settings can reopen into the same org; omitted from the +// block when empty so plain remote connections stay unchanged. +function buildRemoteBlock(remoteUrl, authMode, token, org?: string) { if (authMode !== 'oauth' && !decryptDesktopSecret(token)) { throw new Error('Remote gateway session token is required.') } - return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token } + + const block: { url: string; authMode: string; token: object; org?: string } = { + url: normalizeRemoteBaseUrl(remoteUrl), + authMode, + token + } + const orgValue = typeof org === 'string' ? org.trim() : '' + + if (orgValue) { + block.org = orgValue + } + + return block } -function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) { +function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopConnectionConfig(), options: any = {}) { const persistToken = options.persistToken !== false const key = connectionScopeKey(input.profile) - const mode = input.mode === 'remote' ? 'remote' : 'local' + // 'cloud' and 'remote' both persist a remote-shaped block; 'cloud' is + // remembered as its own provenance (Q6) and resolves to remote downstream. + // Anything else collapses to local. + const mode = modeIsRemoteLike(input.mode) ? input.mode : 'local' + const remoteLike = modeIsRemoteLike(mode) // The block being edited: a per-profile entry or the global remote block. - const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {} + const rawExistingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {} + // Leaving a CLOUD connection unselects it: a cloud block's url/org/token + // describe a discovered Hermes Cloud instance, NOT a user-owned remote gateway, + // so switching to local or remote must NOT inherit them (otherwise the stale + // cloud URL lingers and re-selecting Cloud looks "already connected"). When the + // saved block was cloud and the new mode is not cloud, start from an empty + // block. (remote↔local toggles still preserve a real remote URL as before.) + const existingMode = key ? existing.profiles?.[key]?.mode : existing.mode + const leavingCloud = existingMode === 'cloud' && mode !== 'cloud' + const existingBlock = leavingCloud ? {} : rawExistingBlock const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim() // authMode: explicit input wins; otherwise inherit the saved value, default 'token'. const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode) + // Cloud org: only meaningful for 'cloud' mode. Explicit input wins; otherwise + // inherit the saved org. A plain 'remote' connection never carries an org + // (switching cloud→remote drops it), so it stays unset unless mode is cloud. + const cloudOrg = mode === 'cloud' ? String(input.cloudOrg ?? existingBlock.org ?? '').trim() : '' const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : '' + const nextToken = incomingToken ? persistToken ? encryptDesktopSecret(incomingToken) @@ -4887,21 +5956,27 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect : existingBlock.token if (key) { - // Per-profile scope: a remote entry pins this profile to its own backend; a - // local entry clears the override so the profile inherits the default. + // Per-profile scope: a remote/cloud entry pins this profile to its own + // backend; a local entry clears the override so the profile inherits the + // default. The mode tag (remote vs cloud) is preserved on the entry. const profiles = { ...(existing.profiles || {}) } - if (mode === 'remote') { - profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) } + + if (remoteLike) { + profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) } } else { delete profiles[key] } - return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles } + + return { + mode: modeIsRemoteLike(existing.mode) ? existing.mode : 'local', + remote: existing.remote || {}, + profiles + } } - const nextRemote = - mode === 'remote' - ? buildRemoteBlock(remoteUrl, authMode, nextToken) - : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } + const nextRemote = remoteLike + ? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) + : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } // Preserve per-profile overrides when saving the global connection. return { mode, remote: nextRemote, profiles: existing.profiles || {} } @@ -4928,18 +6003,21 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) { const err = new Error( 'Remote Hermes gateway uses OAuth, but you are not signed in. ' + 'Open Settings → Gateway and click "Sign in", or switch back to Local.' - ) + ) as any + err.needsOauthLogin = true throw err } let ticket + try { ticket = await mintGatewayWsTicket(baseUrl) } catch (error) { const err = new Error( 'Your remote gateway session has expired. ' + 'Open Settings → Gateway and click "Sign in" again.' - ) + ) as any + err.needsOauthLogin = true err.cause = error throw err @@ -4987,14 +6065,17 @@ async function resolveRemoteBackend(profile) { // over the env override so an explicitly-configured profile always // reaches its intended backend. const override = profileRemoteOverride(config, profile) + if (override) { const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token) + return buildRemoteConnection(override.url, override.authMode, token, 'profile') } // 2. Env override (global, token-auth only). const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN + if (rawEnvUrl) { if (!rawEnvToken) { throw new Error( @@ -5002,15 +6083,18 @@ async function resolveRemoteBackend(profile) { 'Both must be provided to connect to a remote Hermes backend.' ) } + return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env') } - // 3. Global remote. - if (config.mode !== 'remote') { + // 3. Global remote (or cloud — cloud resolves to a remote backend, Q6). + if (!modeIsRemoteLike(config.mode)) { return null } + const authMode = normAuthMode(config.remote?.authMode) const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token) + return buildRemoteConnection(config.remote?.url, authMode, token, 'settings') } @@ -5024,17 +6108,20 @@ function profileHasRemoteOverride(profile) { function configuredRemoteProfileNames() { const config = readDesktopConnectionConfig() + return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name)) } // True when the app is in app-global remote mode (Settings → "All profiles" → -// Remote, or the env override): a SINGLE remote backend serves every profile via -// ?profile=. Distinct from per-profile overrides — here there's one host for all. +// Remote/Cloud, or the env override): a SINGLE remote backend serves every +// profile via ?profile=. Cloud counts — it resolves to a remote backend (Q6). +// Distinct from per-profile overrides — here there's one host for all. function globalRemoteActive() { if (process.env.HERMES_DESKTOP_REMOTE_URL) { return true } - return readDesktopConnectionConfig().mode === 'remote' + + return modeIsRemoteLike(readDesktopConnectionConfig().mode) } // GET a profile's resolved backend (remote pool or local primary), parsed JSON. @@ -5043,10 +6130,11 @@ async function fetchJsonForProfile(profile, path) { } // Issue an arbitrary method against a profile's resolved backend, parsed JSON. -async function requestJsonForProfile(profile, path, method, body) { +async function requestJsonForProfile(profile: string, path: string, method: string, body?: string) { const conn = await ensureBackend(profile) const url = `${conn.baseUrl}${path}` const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS } + return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts) } @@ -5066,9 +6154,10 @@ async function probeRemoteAuthMode(rawUrl) { const baseUrl = normalizeRemoteBaseUrl(rawUrl) let status + try { status = await fetchPublicJson(`${baseUrl}/api/status`, { timeoutMs: 8_000 }) - } catch (error) { + } catch (error: any) { return { baseUrl, reachable: false, @@ -5089,7 +6178,8 @@ async function probeRemoteAuthMode(rawUrl) { // an OAuth-redirect one (``supports_password``). A failure here doesn't // change the auth mode, so swallow it. try { - const body = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 }) + const body = (await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 })) as any + if (Array.isArray(body?.providers)) { providers = body.providers .filter(p => p && typeof p === 'object') @@ -5115,14 +6205,16 @@ async function probeRemoteAuthMode(rawUrl) { } } -async function testDesktopConnectionConfig(input = {}) { +async function testDesktopConnectionConfig(input: any = {}) { const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false }) const key = connectionScopeKey(input.profile) // The block under test: a per-profile entry or the global remote. Coerce has // already normalized the URL and resolved token inheritance for the scope. const block = key ? config.profiles?.[key] || null : config.remote + const wantRemote = - block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block) + modeIsRemoteLike(block?.mode) || (!key && modeIsRemoteLike(config.mode)) || (modeIsRemoteLike(input.mode) && block) + // ``/api/status`` is public on every gateway (no creds needed), so a // reachability test works for local, token, and oauth modes alike — we only // need a base URL. For a remote config we normalize the URL from the input; @@ -5130,9 +6222,11 @@ async function testDesktopConnectionConfig(input = {}) { let baseUrl let token = null let authMode = 'token' + if (wantRemote && block?.url) { baseUrl = normalizeRemoteBaseUrl(block.url) authMode = normAuthMode(block.authMode) + if (authMode !== 'oauth') { token = decryptDesktopSecret(block.token) } @@ -5142,7 +6236,8 @@ async function testDesktopConnectionConfig(input = {}) { token = remote.token authMode = normAuthMode(remote.authMode) } - const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 }) + + const status = (await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })) as any // The HTTP status check above proves the backend is reachable, but the chat // surface only works once the renderer's live WebSocket to ``/api/ws`` @@ -5152,11 +6247,13 @@ async function testDesktopConnectionConfig(input = {}) { // connect to Hermes gateway". Mirror the renderer's connect here so the test // reflects the full path the app actually uses. const wsUrl = await resolveTestWsUrl(baseUrl, authMode, token, { mintTicket: mintGatewayWsTicket }) + // Skip the WS leg only when the runtime genuinely lacks a WebSocket (so an // older Electron/Node never fails the test spuriously); Electron's main // process ships a global WebSocket on every supported version. if (wsUrl && typeof globalThis.WebSocket === 'function') { const probe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket }) + if (!probe.ok) { throw new Error( `Reached the gateway over HTTP, but the live WebSocket (/api/ws) connection failed: ${probe.reason} ` + @@ -5186,49 +6283,72 @@ function resetBootProgressForReconnect() { } function stopBackendChild(child) { - if (!child || child.killed) return - try { - if (IS_WINDOWS && Number.isInteger(child.pid)) { - forceKillProcessTree(child.pid) - } else { - child.kill('SIGTERM') - } - } catch { - // Already gone. - } + stopBackendChildImpl(child, { forceKillProcessTree, isWindows: IS_WINDOWS }) } -function resetHermesConnection() { +// Soft gateway-mode apply: tear down the primary without resetting boot UI or +// reloading the renderer. The shell stays up; the renderer wipes session lists +// (so skeletons retrigger) and re-dials. Distinct from hard re-home (profile +// switch / crash recovery), which still resets boot progress + reloads. +function resetHermesConnection({ soft = false } = {}) { connectionPromise = null backendStartFailure = null stopBackendChild(hermesProcess) hermesProcess = null - resetBootProgressForReconnect() + + if (!soft) { + resetBootProgressForReconnect() + } } // Re-home the primary backend: reset connection state, then wait for the live // dashboard process to actually exit (SIGKILL after 5s) so the next // startHermes() spawns fresh instead of racing the dying one. Shared by the // connection-config and profile switch flows. -async function teardownPrimaryBackendAndWait() { +async function teardownPrimaryBackendAndWait({ soft = false } = {}) { // Capture the reference before resetHermesConnection() nulls hermesProcess. const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null - resetHermesConnection() - await waitForBackendExit(dying) + if (soft) { + softRehomeInProgress = true + } + + try { + resetHermesConnection({ soft }) + await waitForBackendExit(dying) + } finally { + if (soft) { + softRehomeInProgress = false + } + } +} + +function sendConnectionApplied() { + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + + const { webContents } = mainWindow + + if (!webContents || webContents.isDestroyed()) { + return + } + + webContents.send('hermes:connection:applied') } async function waitForBackendExit(child, timeoutMs = 5000) { if (!child) { return } + if (child.exitCode !== null || child.signalCode !== null) { return } - await new Promise(resolve => { + await new Promise<void>(resolve => { const timer = setTimeout(() => { try { if (IS_WINDOWS && Number.isInteger(child.pid)) { @@ -5239,8 +6359,10 @@ async function waitForBackendExit(child, timeoutMs = 5000) { } catch { // Already gone. } + resolve() }, timeoutMs) + child.once('exit', () => { clearTimeout(timer) resolve() @@ -5267,8 +6389,10 @@ async function ensureBackend(profile) { } const existing = backendPool.get(key) + if (existing) { existing.lastActiveAt = Date.now() + return existing.connectionPromise } @@ -5281,6 +6405,7 @@ async function ensureBackend(profile) { }) backendPool.set(key, entry) startPoolIdleReaper() + return entry.connectionPromise } @@ -5289,9 +6414,16 @@ async function ensureBackend(profile) { // streaming, since the main process can't see the direct renderer↔backend WS. function touchPoolBackend(profile) { const key = profile && String(profile).trim() ? String(profile).trim() : null - if (!key) return + + if (!key) { + return + } + const entry = backendPool.get(key) - if (entry) entry.lastActiveAt = Date.now() + + if (entry) { + entry.lastActiveAt = Date.now() + } } // Evict least-recently-used pool backends until at most `keep` remain — but only @@ -5299,14 +6431,23 @@ function touchPoolBackend(profile) { // window). When every backend is actively kept alive we let the pool exceed the // soft cap rather than kill a running session. function evictLruPoolBackends(keep) { - if (backendPool.size <= keep) return + if (backendPool.size <= keep) { + return + } + const now = Date.now() + const evictable = [...backendPool.entries()] .filter(([, entry]) => now - (entry.lastActiveAt || 0) > POOL_KEEPALIVE_FRESH_MS) .sort((a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0)) + let removable = backendPool.size - Math.max(0, keep) + for (const [profile] of evictable) { - if (removable <= 0) break + if (removable <= 0) { + break + } + rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`) stopPoolBackend(profile) removable -= 1 @@ -5314,21 +6455,29 @@ function evictLruPoolBackends(keep) { } function startPoolIdleReaper() { - if (poolIdleReaper) return + if (poolIdleReaper) { + return + } + poolIdleReaper = setInterval(() => { const now = Date.now() + for (const [profile, entry] of [...backendPool.entries()]) { if (now - (entry.lastActiveAt || 0) > POOL_IDLE_MS) { rememberLog(`Reaping idle profile backend "${profile}" (idle > ${Math.round(POOL_IDLE_MS / 1000)}s)`) stopPoolBackend(profile) } } + if (backendPool.size === 0 && poolIdleReaper) { clearInterval(poolIdleReaper) poolIdleReaper = null } }, 60_000) - if (typeof poolIdleReaper.unref === 'function') poolIdleReaper.unref() + + if (typeof poolIdleReaper.unref === 'function') { + poolIdleReaper.unref() + } } // Spawn an additional dashboard backend pinned to a named profile. Mirrors the @@ -5342,8 +6491,10 @@ async function spawnPoolBackend(profile, entry) { // entry keeps `entry.process === null`, which stopPoolBackend/evict already // tolerate. const remote = await resolveRemoteBackend(profile) + if (remote) { await waitForHermes(remote.baseUrl, remote.token) + return { ...remote, profile, @@ -5390,6 +6541,7 @@ async function spawnPoolBackend(profile, entry) { stdio: ['ignore', 'pipe', 'pipe'] }) ) + entry.process = child entry.token = token @@ -5398,9 +6550,11 @@ async function spawnPoolBackend(profile, entry) { let ready = false let rejectStart = null + const startFailed = new Promise((_resolve, reject) => { rejectStart = reject }) + child.once('error', error => { rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`) backendPool.delete(profile) @@ -5409,6 +6563,7 @@ async function spawnPoolBackend(profile, entry) { child.once('exit', (code, signal) => { rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`) backendPool.delete(profile) + if (!ready) { rejectStart?.( new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`) @@ -5418,19 +6573,23 @@ async function spawnPoolBackend(profile, entry) { // Discover the ephemeral port the child bound to const port = await Promise.race([waitForDashboardPortAnnouncement(child, { readyFile }), startFailed]) + if (readyFile) { fs.unlink(readyFile, () => {}) } + entry.port = port const baseUrl = `http://127.0.0.1:${port}` await Promise.race([waitForHermes(baseUrl, token), startFailed]) ready = true + const authToken = await adoptServedDashboardToken(baseUrl, token, { childAlive: () => child.exitCode === null && !child.killed, label: `Hermes backend for profile "${profile}"`, rememberLog }) + entry.token = authToken return { @@ -5448,14 +6607,22 @@ async function spawnPoolBackend(profile, entry) { function stopPoolBackend(profile) { const entry = backendPool.get(profile) - if (!entry) return + + if (!entry) { + return + } + backendPool.delete(profile) stopBackendChild(entry.process) } async function teardownPoolBackendAndWait(profile) { const entry = backendPool.get(profile) - if (!entry) return + + if (!entry) { + return + } + backendPool.delete(profile) stopBackendChild(entry.process) @@ -5469,51 +6636,38 @@ function stopAllPoolBackends() { } } -function profileNameFromDeleteRequest(request) { - if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') { - return null - } - - const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/) - if (!match) { - return null - } - - let raw = '' - try { - raw = decodeURIComponent(match[1]) - } catch { - return null - } - - const name = raw.trim() - if (!name) { - return null - } - if (name.toLowerCase() === 'default') { - return 'default' - } - return name.toLowerCase() -} - // Returns the profile name whose backend was torn down, or null when the // request is not a profile-delete. The caller uses this to skip ensureBackend // for the just-torn-down profile — otherwise ensureBackend respawns a pool // backend whose ensure_hermes_home() recreates the deleted profile directory. +// +// The routing *decision* (which branch fires, what profile name gets +// returned) lives in the pure decideProfileDeleteAction() in +// profile-delete-routing.ts; this function only performs the side effects +// that decision calls for. async function prepareProfileDeleteRequest(request) { const profile = profileNameFromDeleteRequest(request) - if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) { + + const decision = decideProfileDeleteAction(profile, { + isDefaultProfile: p => p === 'default', + isValidProfileName: p => PROFILE_NAME_RE.test(p), + primaryProfileKey + }) + + if (decision.action === 'noop') { return null } - if (profile === primaryProfileKey()) { + if (decision.action === 'teardown-primary') { writeActiveDesktopProfile('default') await teardownPrimaryBackendAndWait() - return profile + + return decision.profile } - await teardownPoolBackendAndWait(profile) - return profile + await teardownPoolBackendAndWait(decision.profile) + + return decision.profile } async function startHermes() { @@ -5526,16 +6680,21 @@ async function startHermes() { if (bootstrapFailure) { throw bootstrapFailure } + if (backendStartFailure) { throw backendStartFailure } - if (connectionPromise) return connectionPromise + + if (connectionPromise) { + return connectionPromise + } connectionPromise = (async () => { await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) // Resolve for the desktop's primary profile so a per-profile remote // override on the active profile is honored (falls back to env / global). const remote = await resolveRemoteBackend(primaryProfileKey()) + if (remote) { await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24) await waitForHermes(remote.baseUrl, remote.token) @@ -5546,6 +6705,7 @@ async function startHermes() { running: true, error: null }) + return { baseUrl: remote.baseUrl, mode: 'remote', @@ -5575,9 +6735,11 @@ async function startHermes() { // unset preference keeps the legacy launch so existing installs are // unaffected. const activeProfile = readActiveDesktopProfile() + if (activeProfile) { backendArgs.unshift('--profile', activeProfile) } + await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. @@ -5623,9 +6785,11 @@ async function startHermes() { hermesProcess.stderr.on('data', rememberLog) let backendReady = false let rejectBackendStart = null + const backendStartFailed = new Promise((_resolve, reject) => { rejectBackendStart = reject }) + hermesProcess.once('error', error => { rememberLog(`Hermes backend failed to start: ${error.message}`) updateBootProgress( @@ -5647,6 +6811,7 @@ async function startHermes() { hermesProcess = null connectionPromise = null sendBackendExit({ code, signal }) + if (!backendReady) { const message = `Hermes backend exited before it became ready (${signal || code}).` updateBootProgress( @@ -5667,11 +6832,13 @@ async function startHermes() { }) await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86) + // Discover the ephemeral port the child bound to const port = await Promise.race([ waitForDashboardPortAnnouncement(hermesProcess, { readyFile }), backendStartFailed ]) + if (readyFile) { fs.unlink(readyFile, () => {}) } @@ -5681,11 +6848,13 @@ async function startHermes() { await Promise.race([waitForHermes(baseUrl, token), backendStartFailed]) backendReady = true backendStartFailure = null + const authToken = await adoptServedDashboardToken(baseUrl, token, { // The exit/error handlers null hermesProcess when the child dies. childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed, rememberLog }) + updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready. Finalizing desktop startup', @@ -5729,10 +6898,21 @@ async function startHermes() { // security posture: external links open in the OS browser, in-app navigation // stays confined to the dev server / packaged file URL, and the preview / // devtools / zoom / context-menu affordances behave identically everywhere. -function wireCommonWindowHandlers(win) { +// +// `zoom` is opt-out for the pet overlay: it sizes its own OS window to fit the +// sprite in unzoomed CSS px (overlayWindowSize -> setBounds) and has its own +// Alt+wheel scale, so inheriting the global UI zoom would render the mascot +// larger than its window and crop it. Chat windows keep zoom on. +function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) { installPreviewShortcut(win) installDevToolsShortcut(win) - installZoomShortcuts(win) + if (zoom) { + installZoomShortcuts(win) + // Re-apply persisted zoom on show/restore (Windows drops webContents zoom on + // minimize/restore) and on first load (reloads / crash recovery). + installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win)) + win.webContents.once('did-finish-load', () => restorePersistedZoomLevel(win)) + } installContextMenu(win) win.webContents.setWindowOpenHandler(details => { openExternalUrl(details.url) @@ -5753,18 +6933,32 @@ function wireCommonWindowHandlers(win) { // work with multiple chats side by side. The registry guarantees one window // per sessionId (re-opening focuses the existing window) and self-cleans on // close. The primary mainWindow is never tracked here. Pure logic + the URL -// builder live in session-windows.cjs so they stay unit-testable. +// builder live in session-windows.ts so they stay unit-testable. const sessionWindows = createSessionWindowRegistry() function focusWindow(win) { - if (!win || win.isDestroyed()) return - if (win.isMinimized()) win.restore() - if (!win.isVisible()) win.show() + if (!win || win.isDestroyed()) { + return + } + + if (win.isMinimized()) { + win.restore() + } + + if (!win.isVisible()) { + win.show() + } + win.focus() } -function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { +function spawnSecondaryWindow({ + sessionId, + watch, + newSession +}: { sessionId?: string; watch?: boolean; newSession?: boolean } = {}) { const icon = getAppIconPath() + const win = new BrowserWindow({ width: SESSION_WINDOW_MIN_WIDTH, height: SESSION_WINDOW_MIN_HEIGHT, @@ -5785,7 +6979,7 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { // themes/context.tsx, so the window appears already themed. show: false, backgroundColor: getWindowBackgroundColor(), - webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs')) + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) if (IS_MAC) { @@ -5793,15 +6987,15 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { } win.once('ready-to-show', () => { - if (!win.isDestroyed()) win.show() + if (!win.isDestroyed()) { + win.show() + } }) - win.on('will-enter-full-screen', () => sendWindowStateChanged(true)) win.on('enter-full-screen', () => sendWindowStateChanged(true)) - win.on('will-leave-full-screen', () => sendWindowStateChanged(false)) win.on('leave-full-screen', () => sendWindowStateChanged(false)) - wireCommonWindowHandlers(win) + wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) win.loadURL( buildSessionWindowUrl(sessionId, { @@ -5879,7 +7073,7 @@ function spawnPetOverlayWindow(bounds) { // Fully transparent — the renderer paints only the sprite + bubble. backgroundColor: '#00000000', webPreferences: { - preload: path.join(__dirname, 'preload.cjs'), + preload: PRELOAD_PATH, contextIsolation: true, sandbox: true, nodeIntegration: false, @@ -5896,6 +7090,7 @@ function spawnPetOverlayWindow(bounds) { // switching semantics. win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver') win.setHiddenInMissionControl?.(true) + try { // Electron docs: macOS may transform process type on each // setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true, @@ -5910,10 +7105,14 @@ function spawnPetOverlayWindow(bounds) { // Not supported everywhere — best effort. } - wireCommonWindowHandlers(win) + // Pet overlay opts out of global UI zoom (see zoomWiringForWindowKind): it + // owns its window-fit + scale, and inheriting zoom would crop the sprite. + wireCommonWindowHandlers(win, zoomWiringForWindowKind('petOverlay')) win.once('ready-to-show', () => { - if (!win.isDestroyed()) win.showInactive() + if (!win.isDestroyed()) { + win.showInactive() + } }) win.on('closed', () => { @@ -5991,12 +7190,13 @@ function createWindow() { // Shared with the secondary session windows (chatWindowWebPreferences) so // both keep `backgroundThrottling: false` — the chat transcript streams via // a requestAnimationFrame-gated flush that Chromium pauses for blurred - // windows, stalling the live answer until refocus. See session-windows.cjs. - webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs')) + // windows, stalling the live answer until refocus. See session-windows.ts. + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) if (IS_MAC) { mainWindow.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) + if (icon) { app.dock?.setIcon(icon) } @@ -6011,10 +7211,14 @@ function createWindow() { } } - if (savedWindowState?.isMaximized) mainWindow.maximize() + if (savedWindowState?.isMaximized) { + mainWindow.maximize() + } mainWindow.once('ready-to-show', () => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show() + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show() + } }) mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true)) @@ -6035,7 +7239,7 @@ function createWindow() { // window-all-closed from quitting on Windows/Linux). mainWindow.on('closed', () => closePetOverlay()) - wireCommonWindowHandlers(mainWindow) + wireCommonWindowHandlers(mainWindow, zoomWiringForWindowKind('chat')) mainWindow.webContents.on('render-process-gone', (_event, details) => { rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`) @@ -6054,7 +7258,10 @@ function createWindow() { rendererReloadTimes.push(now) setImmediate(() => { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + try { mainWindow.webContents.reload() } catch (err) { @@ -6074,7 +7281,9 @@ function createWindow() { const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null const level = details ? details.level : detailsOrLevel - if (level !== 3) return + if (level !== 3) { + return + } const text = details ? details.message : message const src = details ? details.sourceUrl : sourceId @@ -6089,7 +7298,8 @@ function createWindow() { } mainWindow.webContents.once('did-finish-load', () => { - restorePersistedZoomLevel(mainWindow) + // Zoom restore is handled by wireCommonWindowHandlers (shared with session + // windows); no need to reapply it here. broadcastBootProgress() sendWindowStateChanged() startHermes().catch(error => rememberLog(error.stack || error.message)) @@ -6111,6 +7321,7 @@ ipcMain.handle('hermes:connection:revalidate', async () => { } let conn = null + try { conn = await connectionPromise } catch { @@ -6124,8 +7335,10 @@ ipcMain.handle('hermes:connection:revalidate', async () => { } const base = conn.baseUrl.replace(/\/+$/, '') + try { await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 }) + return { ok: true, rebuilt: false } } catch { // Unreachable remote: drop the stale cache so the renderer's next reconnect @@ -6133,11 +7346,13 @@ ipcMain.handle('hermes:connection:revalidate', async () => { // nulls connectionPromise for a remote (no child to SIGTERM). rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.') resetHermesConnection() + return { ok: true, rebuilt: true } } }) ipcMain.handle('hermes:backend:touch', async (_event, profile) => { touchPoolBackend(profile) + return { ok: true } }) ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile)) @@ -6167,7 +7382,11 @@ ipcMain.handle('hermes:zoom:get', event => { }) ipcMain.on('hermes:zoom:set-percent', (event, percent) => { const window = BrowserWindow.fromWebContents(event.sender) - if (!window || window.isDestroyed()) return + + if (!window || window.isDestroyed()) { + return + } + setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent))) }) @@ -6250,6 +7469,7 @@ ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { } petOverlayWindow.setFocusable(Boolean(focusable)) + if (focusable) { petOverlayWindow.focus() } @@ -6311,6 +7531,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { completedAt: null, unsupportedPlatform: null } + return { ok: true } }) ipcMain.handle('hermes:bootstrap:repair', async () => { @@ -6319,6 +7540,7 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { // venv), and clear any latched failure + live connection. The renderer // reloads afterwards to re-drive the boot flow from scratch. rememberLog('[bootstrap] repair requested by renderer; clearing marker + latched failure') + try { if (fileExists(BOOTSTRAP_COMPLETE_MARKER)) { fs.rmSync(BOOTSTRAP_COMPLETE_MARKER, { force: true }) @@ -6326,9 +7548,11 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { } catch (error) { rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`) } + bootstrapFailure = null backendStartFailure = null resetHermesConnection() + return { ok: true } }) ipcMain.handle('hermes:bootstrap:cancel', async () => { @@ -6341,8 +7565,10 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => { } catch { void 0 } + return { ok: true, cancelled: true } } + return { ok: false, cancelled: false } }) ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState) @@ -6359,16 +7585,47 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => // the URL defensively so a login can be driven from a raw URL too. const baseUrl = normalizeRemoteBaseUrl(rawUrl) await openOauthLoginWindow(baseUrl) + return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) } }) ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => { const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : '' await clearOauthSession(baseUrl || undefined) + // Report against the SAME liveness notion the Settings indicator uses // (AT-or-RT) so a logout that left any session cookie behind is reflected // as still-connected rather than silently signed-out. return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false } }) + +// --- Hermes Cloud (cloud-auto-discovery Phase 3) --- +// One portal login in the OAuth partition powers both discovery and the silent +// per-agent cascade. See the discovery/cascade helpers above. +ipcMain.handle('hermes:cloud:status', async () => ({ + portalBaseUrl: resolvePortalBaseUrl(), + signedIn: await hasLivePortalSession() +})) +ipcMain.handle('hermes:cloud:login', async () => { + await openPortalLoginWindow() + + return { ok: true, signedIn: await hasLivePortalSession() } +}) +ipcMain.handle('hermes:cloud:logout', async () => { + await clearOauthSession(resolvePortalBaseUrl()) + + return { ok: true, signedIn: await hasLivePortalSession() } +}) +ipcMain.handle('hermes:cloud:discover', async (_event, org) => { + // Returns { agents } or { needsOrgSelection: true, orgs }. `org` (optional) + // scopes discovery to a chosen org for multi-org users. + return discoverCloudAgents(typeof org === 'string' && org ? org : undefined) +}) +ipcMain.handle('hermes:cloud:agent-sign-in', async (_event, dashboardUrl) => { + // Silent per-agent sign-in via the shared portal session. Returns the agent's + // gateway baseUrl + whether its session cookie landed; the renderer then + // saves a cloud-mode connection pointed at this dashboardUrl. + return cloudAgentSilentSignIn(dashboardUrl) +}) ipcMain.handle('hermes:connection-config:save', async (_event, payload) => { const config = coerceDesktopConnectionConfig(payload) writeDesktopConnectionConfig(config) @@ -6387,10 +7644,11 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { // re-resolves against the new remote/local target. stopPoolBackend(key) } else { - // Global connection, or the primary profile's connection: re-home the - // window backend by tearing it down and reloading the renderer. - await teardownPrimaryBackendAndWait() - mainWindow?.reload() + // Global / primary connection: soft re-home. Tear down the window backend + // without resetting boot UI or reloading — the shell stays, the renderer + // wipes session lists (skeletons) and re-dials on hermes:connection:applied. + await teardownPrimaryBackendAndWait({ soft: true }) + sendConnectionApplied() } return sanitizeDesktopConnectionConfig(config, payload?.profile) @@ -6434,25 +7692,32 @@ async function interceptSessionRequestForRemote(request) { if (typeof request?.path !== 'string') { return undefined } + const method = (request.method || 'GET').toUpperCase() let parsed + try { parsed = new URL(request.path, 'http://x') } catch { return undefined } + const { pathname, searchParams } = parsed if (method === 'GET' && pathname === '/api/profiles/sessions') { const remoteProfiles = configuredRemoteProfileNames() + if (remoteProfiles.length === 0) { return undefined // no remote profiles → local fast path } + const requested = (searchParams.get('profile') || 'all').trim() || 'all' + if (requested !== 'all') { return profileHasRemoteOverride(requested) ? remoteSessionList(requested, searchParams) : undefined } + return mergeRemoteProfileSessions(searchParams, remoteProfiles) } @@ -6464,27 +7729,39 @@ async function interceptSessionRequestForRemote(request) { // route there and KEEP the profile param so it opens the right state.db. if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) { const profile = (searchParams.get('profile') || request.profile || '').trim() + if (!profile) { return undefined } + if (profileHasRemoteOverride(profile)) { if (method === 'GET') { return fetchJsonForProfile(profile, pathname) } + const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body - if (body) delete body.profile + + if (body) { + delete body.profile + } + return requestJsonForProfile(profile, pathname, method, body) } + if (globalRemoteActive()) { // Single global backend: keep ?profile= so it opens the right state.db. const sep = pathname.includes('?') ? '&' : '?' const path = `${pathname}${sep}profile=${encodeURIComponent(profile)}` + if (method === 'GET') { return fetchJsonForProfile(null, path) } + const body = request.body && typeof request.body === 'object' ? { ...request.body, profile } : { profile } + return requestJsonForProfile(null, path, method, body) } + return undefined } @@ -6499,11 +7776,13 @@ async function remoteSessionList(profile, searchParams) { const qs = new URLSearchParams(searchParams) qs.delete('profile') // remote serves its own db; no cross-profile read there const data = await fetchJsonForProfile(profile, `/api/sessions?${qs}`) + for (const s of rowsOf(data)) { s.profile = profile s.is_default_profile = false } - return { ...data, sessions: rowsOf(data) } + + return { ...(data as any), sessions: rowsOf(data) } } // Unified list: primary's local aggregate, with each remote profile's stale local @@ -6516,10 +7795,11 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active' const primary = await ensureBackend(null) - const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { + + const base = (await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS - }).catch(() => ({ sessions: [], total: 0, profile_totals: {} })) + }).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))) as any // Over-fetch each remote from offset 0 (limit+offset rows) so the merged window // is correct for this page — mirrors the primary's per-profile over-fetch. @@ -6536,10 +7816,13 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { await Promise.all( remoteProfiles.map(async name => { const list = await remoteSessionList(name, remoteParams).catch(() => null) + if (!list) { delete profileTotals[name] // dead remote → drop its stale local total too + return } + const rows = rowsOf(list) merged.push(...rows) profileTotals[name] = Number(list.total) || rows.length @@ -6549,7 +7832,8 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { const recency = s => s?.[order] ?? s?.started_at ?? 0 merged.sort((a, b) => recency(b) - recency(a)) - return { ...base, sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals } + + return { ...(base as any), sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals } } ipcMain.handle('hermes:api', async (_event, request) => { @@ -6558,6 +7842,7 @@ ipcMain.handle('hermes:api', async (_event, request) => { // profile's sessions live on its remote host, so the UI's IDs 404 (or mutations // no-op) the moment they run there. Route reads + mutations to the remote. const rerouted = await interceptSessionRequestForRemote(request) + if (rerouted !== undefined) { return rerouted } @@ -6569,14 +7854,17 @@ ipcMain.handle('hermes:api', async (_event, request) => { // backend instead of spawning a fresh pool backend. A freshly spawned // backend calls ensure_hermes_home() which recreates the profile directory, // defeating the deletion and leaving a zombie process. - const routeProfile = tornDownProfile ? null : profile + const routeProfile = resolveRouteProfile(tornDownProfile, profile) const connection = await ensureBackend(routeProfile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) + const requestPath = pathWithGlobalRemoteProfile(request.path, profile, { globalRemote: globalRemoteActive(), profileRemoteOverride: profileHasRemoteOverride(profile) }) + const url = `${connection.baseUrl}${requestPath}` + // OAuth gateways authenticate REST via the HttpOnly session cookie held in // the OAuth partition — route through Electron's net stack bound to that // session so the cookie attaches automatically. Token/local modes keep using @@ -6588,6 +7876,7 @@ ipcMain.handle('hermes:api', async (_event, request) => { timeoutMs }) } + return fetchJson(url, connection.token, { method: request?.method, body: request?.body, @@ -6596,31 +7885,45 @@ ipcMain.handle('hermes:api', async (_event, request) => { }) ipcMain.handle('hermes:notify', (_event, payload) => { - if (!Notification.isSupported()) return false + if (!Notification.isSupported()) { + return false + } + // Action buttons render only on signed macOS builds; elsewhere they're dropped // and the body click still works. const actions = Array.isArray(payload?.actions) ? payload.actions : [] + const notification = new Notification({ title: payload?.title || 'Hermes', body: payload?.body || '', silent: Boolean(payload?.silent), actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') })) }) + notification.on('click', () => { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + focusWindow(mainWindow) + if (payload?.sessionId) { mainWindow.webContents.send('hermes:focus-session', payload.sessionId) } }) notification.on('action', (_actionEvent, index) => { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const action = actions[index] + if (action?.id) { mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id }) } }) notification.show() + return true }) @@ -6629,7 +7932,9 @@ ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => { maxBytes: DATA_URL_READ_MAX_BYTES, purpose: 'File preview' }) + const data = await fs.promises.readFile(resolvedPath) + return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}` }) @@ -6638,6 +7943,7 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => { maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES, purpose: 'Text preview' }) + const ext = path.extname(resolvedPath).toLowerCase() const handle = await fs.promises.open(resolvedPath, 'r') const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES) @@ -6660,14 +7966,21 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => { } }) -ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => { +ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => { const properties = options?.directories ? ['openDirectory'] : ['openFile'] - if (options?.multiple !== false) properties.push('multiSelections') + + if (options?.multiple !== false) { + properties.push('multiSelections') + } let resolvedDefaultPath + if (options?.defaultPath) { try { - resolvedDefaultPath = path.resolve(String(options.defaultPath)) + // On a Windows host with a WSL backend the cwd may be a POSIX/WSL path; + // bridge it to a UNC/drive form the native dialog can actually open. + const bridged = IS_WINDOWS ? resolvePickerDefaultPath(String(options.defaultPath)) : String(options.defaultPath) + resolvedDefaultPath = bridged ? path.resolve(bridged) : undefined } catch { resolvedDefaultPath = undefined } @@ -6676,16 +7989,20 @@ ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => { const result = await dialog.showOpenDialog(mainWindow, { title: options?.title || 'Add context', defaultPath: resolvedDefaultPath, - properties, + properties: properties as any, filters: Array.isArray(options?.filters) ? options.filters : undefined }) - if (result.canceled) return [] + if (result.canceled) { + return [] + } + return result.filePaths }) ipcMain.handle('hermes:writeClipboard', (_event, text) => { clipboard.writeText(String(text || '')) + return true }) @@ -6693,14 +8010,19 @@ ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(Stri ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => { const data = payload?.data - if (!data) throw new Error('saveImageBuffer: missing data') + + if (!data) { + throw new Error('saveImageBuffer: missing data') + } const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data) + return writeComposerImage(buffer, payload?.ext || '.png') }) ipcMain.handle('hermes:saveClipboardImage', async () => { const image = clipboard.readImage() + if (image && !image.isEmpty()) { return writeComposerImage(image.toPNG(), '.png') } @@ -6710,6 +8032,7 @@ ipcMain.handle('hermes:saveClipboardImage', async () => { // Pull it straight off the Windows clipboard via PowerShell as a fallback. if (IS_WSL) { const png = readWslWindowsClipboardImage() + if (png) { return writeComposerImage(png, '.png') } @@ -6826,10 +8149,13 @@ ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url)) ipcMain.handle('hermes:logs:reveal', async () => { try { await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) + if (!fileExists(DESKTOP_LOG_PATH)) { await fs.promises.appendFile(DESKTOP_LOG_PATH, '') } + shell.showItemInFolder(DESKTOP_LOG_PATH) + return { ok: true, path: DESKTOP_LOG_PATH } } catch (error) { return { ok: false, path: DESKTOP_LOG_PATH, error: error.message } @@ -6845,6 +8171,7 @@ function isExecutableFile(filePath) { try { fs.accessSync(filePath, fs.constants.X_OK) + return true } catch { return false @@ -6858,39 +8185,6 @@ function posixShellSpec(shellPath) { return { args: interactiveArgs, command: shellPath, name: shellName } } -let spawnHelperChecked = false - -// node-pty execs a `spawn-helper` binary on macOS/Linux to launch the shell in a -// fresh session. The prebuilt that ships in node-pty's `prebuilds/` (and the -// staged copy under resources/native-deps) loses its execute bit through npm -// pack / electron-builder file collection, so every nodePty.spawn() dies with -// "posix_spawnp failed". Restore +x once, lazily, before the first spawn. -function ensureSpawnHelperExecutable() { - if (spawnHelperChecked || IS_WINDOWS || !nodePtyDir) { - return - } - - spawnHelperChecked = true - - const arch = process.arch - const candidates = [ - path.join(nodePtyDir, 'build', 'Release', 'spawn-helper'), - path.join(nodePtyDir, 'prebuilds', `${process.platform}-${arch}`, 'spawn-helper') - ] - - for (const helper of candidates) { - try { - const mode = fs.statSync(helper).mode - - if ((mode & 0o111) !== 0o111) { - fs.chmodSync(helper, mode | 0o755) - } - } catch { - // Not present in this layout (e.g. compiled build vs prebuild); skip. - } - } -} - // Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; // prefer it only after PowerShell 7+ (`pwsh`). function windowsPowerShellPath() { @@ -7002,6 +8296,51 @@ function terminalChannel(id, suffix) { return `hermes:terminal:${id}:${suffix}` } +// Best-effort read of a live PTY child's current working directory so a +// reopened tab can restart the shell where the user last `cd`'d, instead of the +// tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on +// POSIX; Windows has no cheap per-process cwd query without a native module, so +// it returns null and the caller falls back to the launch cwd. +function readProcessCwd(pid) { + return new Promise(resolve => { + if (!Number.isInteger(pid) || pid <= 0) { + resolve(null) + + return + } + + if (process.platform === 'linux') { + fs.promises + .readlink(`/proc/${pid}/cwd`) + .then(target => resolve(target || null)) + .catch(() => resolve(null)) + + return + } + + if (process.platform === 'darwin') { + // lsof ships with macOS; -Fn emits the cwd fd's path on an `n<path>` line. + execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => { + if (err) { + resolve(null) + + return + } + + const line = String(stdout || '') + .split('\n') + .find(entry => entry.startsWith('n')) + + resolve(line ? line.slice(1) : null) + }) + + return + } + + resolve(null) + }) +} + function disposeTerminalSession(id) { const sessionInfo = terminalSessions.get(id) @@ -7127,6 +8466,10 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary())) +ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => + listBaseBranches(repoPath, resolveGitBinary()) +) + // Compact repo status (branch, ahead/behind, change counts + files) for the // composer coding rail. Returns null on a non-repo / remote backend so the rail // hides cleanly rather than erroring. @@ -7180,17 +8523,12 @@ ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { }) ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { - if (!nodePty) { - throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.') - } - - ensureSpawnHelperExecutable() - const id = crypto.randomUUID() const { args, command, name } = terminalShellCommand() const cwd = safeTerminalCwd(payload?.cwd) const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) + const ptyProcess = nodePty.spawn(command, args, { cols, cwd, @@ -7245,6 +8583,16 @@ ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { return true }) +ipcMain.handle('hermes:terminal:cwd', async (_event, id) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return null + } + + return readProcessCwd(sessionInfo.pty.pid) +}) + ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) ipcMain.handle('hermes:updates:check', async () => @@ -7270,6 +8618,7 @@ ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig( ipcMain.handle('hermes:updates:branch:set', async (_event, name) => { const branch = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH writeDesktopUpdateConfig({ branch }) + return { branch } }) @@ -7282,9 +8631,11 @@ function resolveHermesVersion() { try { const root = resolveUpdateRoot() const initPath = path.join(root, 'hermes_cli', '__init__.py') + if (fileExists(initPath)) { const raw = fs.readFileSync(initPath, 'utf8') const match = raw.match(/__version__\s*=\s*["']([^"']+)["']/) + if (match) { return match[1] } @@ -7292,6 +8643,7 @@ function resolveHermesVersion() { } catch { // Fall through to the Electron app version below. } + return app.getVersion() } @@ -7338,6 +8690,7 @@ function uninstallVenvPython() { async function getUninstallSummary() { const py = uninstallVenvPython() const agentRoot = ACTIVE_HERMES_ROOT + // Fast JS-side fallback used when the agent venv is gone (lite client) or the // probe fails — the renderer still needs *something* to render options from. const fallback = () => ({ @@ -7359,11 +8712,16 @@ async function getUninstallSummary() { return new Promise(resolve => { let stdout = '' let settled = false + const done = value => { - if (settled) return + if (settled) { + return + } + settled = true resolve(value) } + try { const child = spawn( py, @@ -7374,12 +8732,16 @@ async function getUninstallSummary() { stdio: ['ignore', 'pipe', 'ignore'] }) ) + child.stdout.on('data', chunk => { stdout += chunk.toString() }) child.on('error', () => done(fallback())) child.on('exit', code => { - if (code !== 0) return done(fallback()) + if (code !== 0) { + return done(fallback()) + } + try { const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}' const parsed = JSON.parse(line) @@ -7401,6 +8763,7 @@ async function getUninstallSummary() { async function runDesktopUninstall(mode) { let uninstallArgs + try { uninstallArgs = uninstallArgsForMode(mode) } catch (error) { @@ -7408,6 +8771,7 @@ async function runDesktopUninstall(mode) { } const venvPy = uninstallVenvPython() + if (!fileExists(venvPy)) { return { ok: false, @@ -7426,8 +8790,10 @@ async function runDesktopUninstall(mode) { // leave venv remnants the user can delete, which we log. let py = venvPy let pythonPath = null + if (modeRemovesAgent(mode)) { const sysPy = findSystemPython() + if (sysPy) { py = sysPy pythonPath = ACTIVE_HERMES_ROOT @@ -7468,6 +8834,7 @@ async function runDesktopUninstall(mode) { let scriptPath let runner let runnerArgs + try { if (IS_WINDOWS) { scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.cmd`) @@ -7490,6 +8857,7 @@ async function runDesktopUninstall(mode) { stdio: 'ignore', windowsHide: true }) + child.unref() } catch (error) { return { ok: false, error: 'spawn-failed', message: error.message } @@ -7504,12 +8872,14 @@ async function runDesktopUninstall(mode) { // the venv python shim + app bundle unlock and the cleanup script can run. isQuittingForHandoff = true setTimeout(() => app.quit(), 800) + return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath } } ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary()) ipcMain.handle('hermes:uninstall:run', async (_event, payload) => { const mode = payload && typeof payload === 'object' ? payload.mode : payload + return runDesktopUninstall(String(mode || '')) }) @@ -7531,19 +8901,28 @@ let _pendingDeepLink = null let _rendererReadyForDeepLink = false function _extractDeepLink(argv) { - if (!Array.isArray(argv)) return null + if (!Array.isArray(argv)) { + return null + } + return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null } function handleDeepLink(url) { - if (!url || typeof url !== 'string') return + if (!url || typeof url !== 'string') { + return + } + let parsed + try { parsed = new URL(url) } catch { rememberLog(`[deeplink] ignoring malformed url: ${url}`) + return } + // hermes://blueprint/<key>?slot=val -> host="blueprint", path="/<key>" const kind = parsed.hostname || '' const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, '')) @@ -7555,10 +8934,15 @@ function handleDeepLink(url) { if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) { _pendingDeepLink = payload + return } + try { - if (mainWindow.isMinimized()) mainWindow.restore() + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.focus() mainWindow.webContents.send('hermes:deep-link', payload) rememberLog(`[deeplink] delivered ${kind}/${name}`) @@ -7571,6 +8955,7 @@ function handleDeepLink(url) { // a link that arrived during boot/install is flushed exactly once. ipcMain.handle('hermes:deep-link-ready', () => { _rendererReadyForDeepLink = true + if (_pendingDeepLink) { const queued = _pendingDeepLink _pendingDeepLink = null @@ -7579,6 +8964,7 @@ ipcMain.handle('hermes:deep-link-ready', () => { (Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '') ) } + return { ok: true } }) @@ -7600,14 +8986,20 @@ function registerDeepLinkProtocol() { // second-instance argv. Without the lock a second `hermes://` launch spawns a // whole new app instead of routing into the running one. const _gotSingleInstanceLock = app.requestSingleInstanceLock() + if (!_gotSingleInstanceLock) { app.quit() } else { app.on('second-instance', (_event, argv) => { const url = _extractDeepLink(argv) - if (url) handleDeepLink(url) - else if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore() + + if (url) { + handleDeepLink(url) + } else if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.focus() } }) @@ -7626,6 +9018,7 @@ app.whenReady().then(() => { } else { Menu.setApplicationMenu(null) } + installMediaPermissions() registerMediaProtocol() installEmbedReferer() @@ -7637,7 +9030,10 @@ app.whenReady().then(() => { // Win/Linux cold start: the launching hermes:// URL is in our own argv. const _coldStartLink = _extractDeepLink(process.argv) - if (_coldStartLink) handleDeepLink(_coldStartLink) + + if (_coldStartLink) { + handleDeepLink(_coldStartLink) + } app.on('activate', () => { // Recreate the primary window if it's gone. Guard on mainWindow directly @@ -7692,6 +9088,7 @@ app.on('before-quit', () => { clearTimeout(desktopLogFlushTimer) desktopLogFlushTimer = null } + flushDesktopLogBufferSync() closePreviewWatchers() @@ -7712,5 +9109,7 @@ app.on('window-all-closed', () => { // the bundle and relaunch — without this the script's PID-wait spins to its // full timeout and the user is left with an invisible app (or an uninstall // that appears to do nothing). - if (process.platform !== 'darwin' || isQuittingForHandoff) app.quit() + if (process.platform !== 'darwin' || isQuittingForHandoff) { + app.quit() + } }) diff --git a/apps/desktop/electron/oauth-net-request.test.cjs b/apps/desktop/electron/oauth-net-request.test.ts similarity index 78% rename from apps/desktop/electron/oauth-net-request.test.cjs rename to apps/desktop/electron/oauth-net-request.test.ts index 63a27f6219a..631119363f0 100644 --- a/apps/desktop/electron/oauth-net-request.test.cjs +++ b/apps/desktop/electron/oauth-net-request.test.ts @@ -1,13 +1,14 @@ /** * Tests for OAuth-session Electron net.request helpers. * - * Run with: node --test electron/oauth-net-request.test.cjs + * Run with: node --test electron/oauth-net-request.test.ts */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' -const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') +import { test } from 'vitest' + +import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' test('serializeJsonBody returns undefined for absent bodies', () => { assert.equal(serializeJsonBody(undefined), undefined) @@ -21,6 +22,7 @@ test('serializeJsonBody JSON-encodes request bodies', () => { test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => { const headers = [] + const request = { setHeader(name, value) { headers.push([name, value]) diff --git a/apps/desktop/electron/oauth-net-request.cjs b/apps/desktop/electron/oauth-net-request.ts similarity index 87% rename from apps/desktop/electron/oauth-net-request.cjs rename to apps/desktop/electron/oauth-net-request.ts index 0498a7333fa..bab5ef53f69 100644 --- a/apps/desktop/electron/oauth-net-request.cjs +++ b/apps/desktop/electron/oauth-net-request.ts @@ -14,7 +14,4 @@ function setJsonRequestHeaders(request) { request.setHeader('Content-Type', 'application/json') } -module.exports = { - serializeJsonBody, - setJsonRequestHeaders -} +export { serializeJsonBody, setJsonRequestHeaders } diff --git a/apps/desktop/electron/oauth-session-request.test.cjs b/apps/desktop/electron/oauth-session-request.test.cjs deleted file mode 100644 index 3254318456b..00000000000 --- a/apps/desktop/electron/oauth-session-request.test.cjs +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Regression coverage for the OAuth-session Electron net.request path. - * - * Electron net rejects manual Content-Length/Host headers with - * net::ERR_INVALID_ARGUMENT. Node HTTP helpers may still set Content-Length; - * this guard is scoped to fetchJsonViaOauthSession only. - */ - -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') - -const source = fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8') - -function extractFetchJsonViaOauthSession() { - const start = source.indexOf('function fetchJsonViaOauthSession') - const end = source.indexOf('// Mint a single-use WS ticket', start) - assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist') - assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist') - return source.slice(start, end) -} - -test('OAuth Electron net request does not set forbidden Content-Length header', () => { - const fn = extractFetchJsonViaOauthSession() - - assert.match(fn, /electronNet\.request/) - assert.doesNotMatch(fn, /setHeader\(['"]Content-Length['"]/) - assert.match(fn, /request\.write\(body\)/) -}) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.ts similarity index 92% rename from apps/desktop/electron/preload.cjs rename to apps/desktop/electron/preload.ts index 0a9c4fd7921..6c1ebc5bf64 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.ts @@ -1,4 +1,4 @@ -const { contextBridge, ipcRenderer, webUtils } = require('electron') +import { contextBridge, ipcRenderer, webUtils } from 'electron' contextBridge.exposeInMainWorld('hermesDesktop', { getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), @@ -24,12 +24,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onState: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:pet-overlay:state', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener) }, // Main renderer subscribes to overlay control messages. onControl: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:pet-overlay:control', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener) } }, @@ -41,6 +43,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', { probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl), oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl), oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl), + // Hermes Cloud: one portal login powers discovery + silent per-agent sign-in + // (cloud-auto-discovery Phase 3). + cloud: { + status: () => ipcRenderer.invoke('hermes:cloud:status'), + login: () => ipcRenderer.invoke('hermes:cloud:login'), + logout: () => ipcRenderer.invoke('hermes:cloud:logout'), + discover: org => ipcRenderer.invoke('hermes:cloud:discover', org), + agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl) + }, profile: { get: () => ipcRenderer.invoke('hermes:profile:get'), set: name => ipcRenderer.invoke('hermes:profile:set', name) @@ -87,6 +98,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:zoom:changed', listener) + return () => ipcRenderer.removeListener('hermes:zoom:changed', listener) } }, @@ -105,6 +117,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options), branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch), branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath), + baseBranchList: repoPath => ipcRenderer.invoke('hermes:git:baseBranchList', repoPath), repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath), fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath), scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options), @@ -124,6 +137,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { } }, terminal: { + cwd: id => ipcRenderer.invoke('hermes:terminal:cwd', id), dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id), resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size), start: options => ipcRenderer.invoke('hermes:terminal:start', options), @@ -132,68 +146,88 @@ contextBridge.exposeInMainWorld('hermesDesktop', { const channel = `hermes:terminal:${id}:data` const listener = (_event, payload) => callback(payload) ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) }, onExit: (id, callback) => { const channel = `hermes:terminal:${id}:exit` const listener = (_event, payload) => callback(payload) ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) } }, onClosePreviewRequested: callback => { const listener = () => callback() ipcRenderer.on('hermes:close-preview-requested', listener) + return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener) }, onOpenUpdatesRequested: callback => { const listener = () => callback() ipcRenderer.on('hermes:open-updates', listener) + return () => ipcRenderer.removeListener('hermes:open-updates', listener) }, onDeepLink: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:deep-link', listener) + return () => ipcRenderer.removeListener('hermes:deep-link', listener) }, signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'), onWindowStateChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:window-state-changed', listener) + return () => ipcRenderer.removeListener('hermes:window-state-changed', listener) }, onFocusSession: callback => { const listener = (_event, sessionId) => callback(sessionId) ipcRenderer.on('hermes:focus-session', listener) + return () => ipcRenderer.removeListener('hermes:focus-session', listener) }, onNotificationAction: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:notification-action', listener) + return () => ipcRenderer.removeListener('hermes:notification-action', listener) }, onPreviewFileChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:preview-file-changed', listener) + return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener) }, onBackendExit: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:backend-exit', listener) + return () => ipcRenderer.removeListener('hermes:backend-exit', listener) }, + // Soft gateway-mode apply finished tearing down the primary backend. Renderer + // should wipe session lists + re-dial without a window reload. + onConnectionApplied: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:connection:applied', listener) + + return () => ipcRenderer.removeListener('hermes:connection:applied', listener) + }, onPowerResume: callback => { const listener = () => callback() ipcRenderer.on('hermes:power-resume', listener) + return () => ipcRenderer.removeListener('hermes:power-resume', listener) }, onBootProgress: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:boot-progress', listener) + return () => ipcRenderer.removeListener('hermes:boot-progress', listener) }, // First-launch bootstrap progress -- emitted by the install.ps1 stage - // runner in main.cjs (apps/desktop/electron/bootstrap-runner.cjs). + // runner in main.ts (apps/desktop/electron/bootstrap-runner.ts). // Renderer's install overlay subscribes to live events and queries the // current snapshot via getBootstrapState() to recover after a devtools // reload mid-bootstrap. @@ -204,6 +238,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onBootstrapEvent: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:bootstrap:event', listener) + return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener) }, getVersion: () => ipcRenderer.invoke('hermes:version'), @@ -220,6 +255,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onProgress: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:updates:progress', listener) + return () => ipcRenderer.removeListener('hermes:updates:progress', listener) } }, diff --git a/apps/desktop/electron/profile-delete-respawn.test.cjs b/apps/desktop/electron/profile-delete-respawn.test.cjs deleted file mode 100644 index 07e17f78749..00000000000 --- a/apps/desktop/electron/profile-delete-respawn.test.cjs +++ /dev/null @@ -1,62 +0,0 @@ -'use strict' - -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') - -const ELECTRON_DIR = __dirname - -function readElectronFile(name) { - return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') -} - -// --------------------------------------------------------------------------- -// prepareProfileDeleteRequest must return the torn-down profile name so the -// caller can skip ensureBackend for that profile (issue #52279). -// --------------------------------------------------------------------------- - -test('prepareProfileDeleteRequest returns the torn-down profile name', () => { - const source = readElectronFile('main.cjs') - - // Locate the function definition and its closing brace. - const fnStart = source.indexOf('async function prepareProfileDeleteRequest(') - assert.notEqual(fnStart, -1, 'prepareProfileDeleteRequest function not found') - - // The function must contain "return profile" (pool and primary paths). - const fnBody = source.slice(fnStart, fnStart + 800) - const returnProfileCount = (fnBody.match(/return profile/g) || []).length - assert.ok( - returnProfileCount >= 2, - `expected at least 2 "return profile" statements (primary + pool paths), found ${returnProfileCount}` - ) - - // The early-exit guard must return null (not void/undefined). - assert.match(fnBody, /return null/, 'early-exit guard should return null, not undefined') -}) - -test('hermes:api handler routes profile-delete requests to the primary backend', () => { - const source = readElectronFile('main.cjs') - - // The handler must capture prepareProfileDeleteRequest's return value. - assert.match( - source, - /const tornDownProfile = await prepareProfileDeleteRequest\(request\)/, - 'handler should capture the return value of prepareProfileDeleteRequest' - ) - - // The handler must use the return value to skip ensureBackend for the - // torn-down profile, routing to the primary (null) instead. - assert.match( - source, - /const routeProfile = tornDownProfile \? null : profile/, - 'handler should route to primary backend when a profile was just torn down' - ) - - // ensureBackend must be called with the conditional route profile. - assert.match( - source, - /const connection = await ensureBackend\(routeProfile\)/, - 'handler should pass routeProfile (not raw profile) to ensureBackend' - ) -}) diff --git a/apps/desktop/electron/profile-delete-routing.test.ts b/apps/desktop/electron/profile-delete-routing.test.ts new file mode 100644 index 00000000000..6b1bb01da1e --- /dev/null +++ b/apps/desktop/electron/profile-delete-routing.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' + +// --------------------------------------------------------------------------- +// profileNameFromDeleteRequest +// --------------------------------------------------------------------------- + +test('profileNameFromDeleteRequest parses a DELETE /api/profiles/<name> path', () => { + assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/worker' }), 'worker') +}) + +test('profileNameFromDeleteRequest lowercases the profile name', () => { + assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/Worker' }), 'worker') +}) + +test('profileNameFromDeleteRequest returns null for non-DELETE methods', () => { + assert.equal(profileNameFromDeleteRequest({ method: 'GET', path: '/api/profiles/worker' }), null) +}) + +test('profileNameFromDeleteRequest returns null when the path does not match', () => { + assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/sessions' }), null) +}) + +test('profileNameFromDeleteRequest returns null for an empty/whitespace name', () => { + assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/%20' }), null) +}) + +test('profileNameFromDeleteRequest returns null for an undecodable path segment', () => { + assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/%E0%A4%A' }), null) +}) + +// --------------------------------------------------------------------------- +// decideProfileDeleteAction +// --------------------------------------------------------------------------- + +const deps = { + isDefaultProfile: p => p === 'default', + isValidProfileName: p => /^[a-z0-9][a-z0-9_-]{0,63}$/.test(p), + primaryProfileKey: () => 'primary-profile' +} + +test('decideProfileDeleteAction is a noop for the default profile', () => { + assert.deepEqual(decideProfileDeleteAction('default', deps), { action: 'noop', profile: null }) +}) + +test('decideProfileDeleteAction is a noop for null (no profile parsed)', () => { + assert.deepEqual(decideProfileDeleteAction(null, deps), { action: 'noop', profile: null }) +}) + +test('decideProfileDeleteAction is a noop for an invalid profile name', () => { + assert.deepEqual(decideProfileDeleteAction('Not Valid!', deps), { action: 'noop', profile: null }) +}) + +test('decideProfileDeleteAction tears down the primary backend for the primary profile', () => { + assert.deepEqual(decideProfileDeleteAction('primary-profile', deps), { + action: 'teardown-primary', + profile: 'primary-profile' + }) +}) + +test('decideProfileDeleteAction tears down the pool backend for any other valid profile', () => { + assert.deepEqual(decideProfileDeleteAction('worker', deps), { action: 'teardown-pool', profile: 'worker' }) +}) + +// --------------------------------------------------------------------------- +// resolveRouteProfile +// --------------------------------------------------------------------------- + +test('resolveRouteProfile routes to the primary backend (null) when a profile was torn down', () => { + assert.equal(resolveRouteProfile('worker', 'other-profile'), null) +}) + +test('resolveRouteProfile passes the requested profile through when nothing was torn down', () => { + assert.equal(resolveRouteProfile(null, 'other-profile'), 'other-profile') +}) + +test('resolveRouteProfile passes through undefined when nothing was torn down and no profile was requested', () => { + assert.equal(resolveRouteProfile(null, undefined), undefined) +}) diff --git a/apps/desktop/electron/profile-delete-routing.ts b/apps/desktop/electron/profile-delete-routing.ts new file mode 100644 index 00000000000..d8ba5b48e78 --- /dev/null +++ b/apps/desktop/electron/profile-delete-routing.ts @@ -0,0 +1,95 @@ +// Profile-delete routing logic for the `hermes:api` IPC handler. +// +// When the renderer issues DELETE /api/profiles/<name>, the handler must +// tear down that profile's backend (primary window backend or pool backend) +// and then route the *next* request away from the just-deleted profile's +// pool backend -- spawning a fresh one would call ensure_hermes_home() and +// recreate the profile directory the delete just removed, leaving a zombie +// process behind (issue #52279). +// +// These helpers are pure so they can be unit-tested without Electron. + +/** + * Parse a `hermes:api` request into the profile name a DELETE targets, or + * null when the request is not a profile-delete at all (wrong method, wrong + * path, empty/invalid name). + */ +export function profileNameFromDeleteRequest(request) { + if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') { + return null + } + + const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/) + + if (!match) { + return null + } + + let raw = '' + + try { + raw = decodeURIComponent(match[1]) + } catch { + return null + } + + const name = raw.trim() + + if (!name) { + return null + } + + if (name.toLowerCase() === 'default') { + return 'default' + } + + return name.toLowerCase() +} + +export type ProfileDeleteAction = 'noop' | 'teardown-primary' | 'teardown-pool' + +export interface ProfileDeleteDecision { + action: ProfileDeleteAction + profile: string | null +} + +export interface ProfileDeleteDecisionDeps { + isDefaultProfile: (profile: string) => boolean + isValidProfileName: (profile: string) => boolean + primaryProfileKey: () => string +} + +/** + * Pure decision logic for prepareProfileDeleteRequest: given the parsed + * profile name (or null), decide which side-effecting branch the caller + * should take and what profile name it should ultimately report as + * torn-down. No I/O, no async -- the caller performs the actual teardown + * based on `action`. + */ +export function decideProfileDeleteAction( + profile: string | null, + deps: ProfileDeleteDecisionDeps +): ProfileDeleteDecision { + if (!profile || deps.isDefaultProfile(profile) || !deps.isValidProfileName(profile)) { + return { action: 'noop', profile: null } + } + + if (profile === deps.primaryProfileKey()) { + return { action: 'teardown-primary', profile } + } + + return { action: 'teardown-pool', profile } +} + +/** + * Route the next `hermes:api` request away from the primary/window backend + * whenever a profile was just torn down -- otherwise ensureBackend would + * spawn a fresh pool backend for the deleted profile, whose + * ensure_hermes_home() recreates the directory the delete just removed. + */ +export function resolveRouteProfile( + tornDownProfile: string | null, + profile: string | undefined +): string | null | undefined { + return tornDownProfile ? null : profile +} diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.ts similarity index 96% rename from apps/desktop/electron/session-windows.test.cjs rename to apps/desktop/electron/session-windows.test.ts index 78f19b859e4..fcfca868073 100644 --- a/apps/desktop/electron/session-windows.test.cjs +++ b/apps/desktop/electron/session-windows.test.ts @@ -1,11 +1,8 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' -const { - buildSessionWindowUrl, - chatWindowWebPreferences, - createSessionWindowRegistry -} = require('./session-windows.cjs') +import { test } from 'vitest' + +import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows' // A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a // test fire the 'closed' event, mirroring the slice of the Electron API the @@ -96,6 +93,7 @@ test('registry opens one window per session and focuses on re-open', () => { const registry = createSessionWindowRegistry() let built = 0 const win = makeFakeWindow() + const factory = () => { built += 1 @@ -145,6 +143,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', () let built = 0 const second = makeFakeWindow() + const result = registry.openOrFocus('s1', () => { built += 1 @@ -158,6 +157,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', () test('registry ignores empty / non-string session ids', () => { const registry = createSessionWindowRegistry() let built = 0 + const factory = () => { built += 1 diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.ts similarity index 91% rename from apps/desktop/electron/session-windows.cjs rename to apps/desktop/electron/session-windows.ts index 5e2f3d4c680..af55608b0f4 100644 --- a/apps/desktop/electron/session-windows.cjs +++ b/apps/desktop/electron/session-windows.ts @@ -1,9 +1,9 @@ // Secondary "session windows" — one extra OS window per chat so a user can // work with multiple chats side by side. The pure, Electron-free pieces live // here so they can be unit-tested with node --test (mirroring how the rest of -// electron/*.cjs splits testable logic out of the main.cjs monolith). +// electron/*.ts splits testable logic out of the main.ts monolith). -const { pathToFileURL } = require('node:url') +import { pathToFileURL } from 'node:url' // Secondary windows open at the minimum usable size — a compact side panel for // subagent watch / cmd-click session pop-out, not a second full desktop. @@ -12,7 +12,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // Shared webPreferences for every window that renders the chat transcript — the // primary window AND the secondary session windows. Keeping it in one place is -// the whole point: the two BrowserWindow definitions in main.cjs used to be +// the whole point: the two BrowserWindow definitions in main.ts used to be // hand-copied, and the secondary windows silently lost `backgroundThrottling: // false`, so a streamed answer stalled until the window regained focus. // @@ -21,7 +21,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // blurred/occluded windows. A streaming chat app must keep painting in the // background, so every chat window opts out. The preload path is injected // because it depends on the Electron entry's __dirname. -function chatWindowWebPreferences(preloadPath) { +function chatWindowWebPreferences(preloadPath: string) { return { preload: preloadPath, contextIsolation: true, @@ -42,7 +42,7 @@ function chatWindowWebPreferences(preloadPath) { // scratch window; `watch=1` marks a spectator window (e.g. a running subagent's // session): the renderer resumes it lazily so the gateway never builds an agent // just to stream into it. -function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) { +function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) { const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}` const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}` @@ -115,7 +115,7 @@ function createSessionWindowRegistry() { } } -module.exports = { +export { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, diff --git a/apps/desktop/electron/titlebar-overlay-width.test.cjs b/apps/desktop/electron/titlebar-overlay-width.test.ts similarity index 92% rename from apps/desktop/electron/titlebar-overlay-width.test.cjs rename to apps/desktop/electron/titlebar-overlay-width.test.ts index ccec1015b4f..0e424fd5c3e 100644 --- a/apps/desktop/electron/titlebar-overlay-width.test.cjs +++ b/apps/desktop/electron/titlebar-overlay-width.test.ts @@ -1,12 +1,13 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' -const { +import { test } from 'vitest' + +import { MACOS_TAHOE_DARWIN_MAJOR, - OVERLAY_FALLBACK_WIDTH, macTitleBarOverlayHeight, - nativeOverlayWidth -} = require('./titlebar-overlay-width.cjs') + nativeOverlayWidth, + OVERLAY_FALLBACK_WIDTH +} from './titlebar-overlay-width' // This static reservation is only the pre-layout FALLBACK. Once laid out the // renderer reads the exact width from navigator.windowControlsOverlay diff --git a/apps/desktop/electron/titlebar-overlay-width.cjs b/apps/desktop/electron/titlebar-overlay-width.ts similarity index 80% rename from apps/desktop/electron/titlebar-overlay-width.cjs rename to apps/desktop/electron/titlebar-overlay-width.ts index 9336ae89fce..d6a4c5d1f24 100644 --- a/apps/desktop/electron/titlebar-overlay-width.cjs +++ b/apps/desktop/electron/titlebar-overlay-width.ts @@ -1,6 +1,4 @@ -'use strict' - -const OVERLAY_FALLBACK_WIDTH = 144 +export const OVERLAY_FALLBACK_WIDTH = 144 /** * Static pre-layout reservation (px) for the right-side native window-controls @@ -16,15 +14,18 @@ const OVERLAY_FALLBACK_WIDTH = 144 * * @param {{ isMac?: boolean }} opts */ -function nativeOverlayWidth({ isMac = false } = {}) { - if (isMac) return 0 +export function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) { + if (isMac) { + return 0 + } + return OVERLAY_FALLBACK_WIDTH } // macOS Tahoe ships as Darwin 25 (Sequoia is 24); the Darwin number is truthful, // unlike the product version which macOS reports as 16 or 26 depending on the // build SDK. -const MACOS_TAHOE_DARWIN_MAJOR = 25 +export const MACOS_TAHOE_DARWIN_MAJOR = 25 /** * Height (px) to pass to `titleBarOverlay` on macOS. Tahoe (Darwin 25+) @@ -36,8 +37,6 @@ const MACOS_TAHOE_DARWIN_MAJOR = 25 * * @param {{ darwinMajor?: number, titlebarHeight?: number }} opts */ -function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) { +export function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) { return darwinMajor >= MACOS_TAHOE_DARWIN_MAJOR ? 0 : titlebarHeight } - -module.exports = { MACOS_TAHOE_DARWIN_MAJOR, OVERLAY_FALLBACK_WIDTH, macTitleBarOverlayHeight, nativeOverlayWidth } diff --git a/apps/desktop/electron/update-count.test.cjs b/apps/desktop/electron/update-count.test.ts similarity index 94% rename from apps/desktop/electron/update-count.test.cjs rename to apps/desktop/electron/update-count.test.ts index fdac4fd744a..b7e1e89a658 100644 --- a/apps/desktop/electron/update-count.test.cjs +++ b/apps/desktop/electron/update-count.test.ts @@ -1,7 +1,8 @@ -'use strict' -const test = require('node:test') -const assert = require('node:assert/strict') -const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { resolveBehindCount, shouldCountCommits } from './update-count' // FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0` // unconditionally, so a shallow checkout with no merge-base surfaced the bogus diff --git a/apps/desktop/electron/update-count.cjs b/apps/desktop/electron/update-count.ts similarity index 90% rename from apps/desktop/electron/update-count.cjs rename to apps/desktop/electron/update-count.ts index de8d57c4ee6..23fb6cac134 100644 --- a/apps/desktop/electron/update-count.cjs +++ b/apps/desktop/electron/update-count.ts @@ -1,5 +1,3 @@ -'use strict' - // Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful // number worth computing. On a SHALLOW checkout (installer clones with // --depth 1) the local history often shares no merge-base with the freshly @@ -19,10 +17,14 @@ function shouldCountCommits({ isShallow, hasMergeBase }) { // (developers / Docker dev images) keep the exact count path unchanged. function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) { if (!shouldCountCommits({ isShallow, hasMergeBase })) { - if (currentSha && targetSha && currentSha === targetSha) return 0 + if (currentSha && targetSha && currentSha === targetSha) { + return 0 + } + return 1 // behind by an unknown amount — show a generic "update available" } + return Number.parseInt(countStr, 10) || 0 } -module.exports = { resolveBehindCount, shouldCountCommits } +export { resolveBehindCount, shouldCountCommits } diff --git a/apps/desktop/electron/update-marker.test.cjs b/apps/desktop/electron/update-marker.test.ts similarity index 87% rename from apps/desktop/electron/update-marker.test.cjs rename to apps/desktop/electron/update-marker.test.ts index d84483714c6..3da49396cc8 100644 --- a/apps/desktop/electron/update-marker.test.cjs +++ b/apps/desktop/electron/update-marker.test.ts @@ -1,9 +1,9 @@ /** - * Tests for electron/update-marker.cjs — the in-app update mutual-exclusion + * Tests for electron/update-marker.ts — the in-app update mutual-exclusion * marker that prevents a desktop relaunched mid-update from spawning a backend * the updater then kills in a loop (#50238). * - * Run with: node --test electron/update-marker.test.cjs + * Run with: node --test electron/update-marker.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * Why this matters: the gate must (a) report a live update only when the @@ -12,16 +12,24 @@ * strand future launches, and (c) self-heal by deleting a stale marker file. */ -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('fs') -const os = require('os') -const path = require('path') +import fs from 'fs' +import assert from 'node:assert/strict' +import os from 'os' +import path from 'path' -const { markerPath, isPidAlive, readLiveUpdateMarker, writeUpdateMarker, UPDATE_MARKER_MAX_AGE_MS } = require('./update-marker.cjs') +import { test } from 'vitest' + +import { + isPidAlive, + markerPath, + readLiveUpdateMarker, + UPDATE_MARKER_MAX_AGE_MS, + writeUpdateMarker +} from './update-marker' function tmpHome(tag) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-marker-${tag}-`)) + return dir } @@ -29,10 +37,12 @@ function writeMarker(home, pid, startedAtSec) { fs.writeFileSync(markerPath(home), `${pid}\n${startedAtSec}`) } -const ALIVE = () => true // injected kill that "succeeds" => pid alive -const DEAD = () => { +const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" => pid alive + +const DEAD: typeof process.kill = () => { const err = new Error('no such process') - err.code = 'ESRCH' + + ;(err as any).code = 'ESRCH' throw err } @@ -85,9 +95,11 @@ test('isPidAlive: own pid is alive, impossible pid is dead', () => { test('isPidAlive: EPERM counts as alive (process owned by another user)', () => { const eperm = () => { const err = new Error('operation not permitted') - err.code = 'EPERM' + + ;(err as any).code = 'EPERM' throw err } + assert.equal(isPidAlive(4242, eperm), true) }) diff --git a/apps/desktop/electron/update-marker.cjs b/apps/desktop/electron/update-marker.ts similarity index 87% rename from apps/desktop/electron/update-marker.cjs rename to apps/desktop/electron/update-marker.ts index da3df6a8d4a..543fce0451d 100644 --- a/apps/desktop/electron/update-marker.cjs +++ b/apps/desktop/electron/update-marker.ts @@ -16,20 +16,20 @@ * * This module holds the PURE, side-effect-light logic (path, pid liveness, * parse + staleness) so it is unit-testable without booting Electron. The - * polling/boot-progress wrapper lives in main.cjs where the boot-progress and + * polling/boot-progress wrapper lives in main.ts where the boot-progress and * log sinks are. */ -const fs = require('fs') -const path = require('path') +import fs from 'fs' +import path from 'path' // Even with a live-looking PID, never treat a marker older than this as a live // update. A full update (git pull + pip + desktop rebuild) is minutes, not tens // of minutes; past this the marker is almost certainly stale (e.g. the OS // recycled the pid onto an unrelated process), so the gate self-heals. -const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000 +export const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000 -function markerPath(hermesHome) { +export function markerPath(hermesHome) { return path.join(hermesHome, '.hermes-update-in-progress') } @@ -37,10 +37,14 @@ function markerPath(hermesHome) { // not deliver a signal — it just probes existence/permission. ESRCH => dead; // EPERM => alive but owned by another user (still "alive" for our purposes). // Injectable `kill` keeps it unit-testable. -function isPidAlive(pid, kill = process.kill.bind(process)) { - if (!Number.isInteger(pid) || pid <= 0) return false +export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(process)) { + if (!Number.isInteger(pid) || pid <= 0) { + return false + } + try { kill(pid, 0) + return true } catch (err) { return Boolean(err && err.code === 'EPERM') @@ -59,9 +63,21 @@ function isPidAlive(pid, kill = process.kill.bind(process)) { * Pure-ish: file I/O against the given path, plus an injectable pid probe and * clock for tests. */ -function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS } = {}) { +export function readLiveUpdateMarker( + hermesHome, + { + kill, + now = Date.now, + maxAgeMs = UPDATE_MARKER_MAX_AGE_MS + }: { + now?: () => number + maxAgeMs?: number + kill?: typeof process.kill + } = {} +) { const file = markerPath(hermesHome) let raw + try { raw = fs.readFileSync(file, 'utf8') } catch { @@ -80,8 +96,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD } catch { void 0 } + return null } + return { pid, ageMs } } @@ -107,9 +125,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD * If the updater never starts (spawn failure) the marker still contains a * real PID, so `readLiveUpdateMarker` will self-heal once that PID exits. */ -function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { +export function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { const file = markerPath(hermesHome) const startedAt = Math.floor(now() / 1000) + try { fs.writeFileSync(file, `${pid}\n${startedAt}\n`, 'utf8') } catch { @@ -117,11 +136,3 @@ function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { // updater will write its own when it reaches run_update. } } - -module.exports = { - UPDATE_MARKER_MAX_AGE_MS, - markerPath, - isPidAlive, - readLiveUpdateMarker, - writeUpdateMarker -} diff --git a/apps/desktop/electron/update-rebuild.test.cjs b/apps/desktop/electron/update-rebuild.test.ts similarity index 84% rename from apps/desktop/electron/update-rebuild.test.cjs rename to apps/desktop/electron/update-rebuild.test.ts index 623effa4d13..6c2d7524550 100644 --- a/apps/desktop/electron/update-rebuild.test.cjs +++ b/apps/desktop/electron/update-rebuild.test.ts @@ -1,8 +1,8 @@ /** - * Tests for electron/update-rebuild.cjs — the retry-once policy for the desktop + * Tests for electron/update-rebuild.ts — the retry-once policy for the desktop * `--build-only` rebuild during self-update. * - * Run with: node --test electron/update-rebuild.test.cjs + * Run with: node --test electron/update-rebuild.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * Why this matters: a first rebuild can return nonzero on a still-settling tree @@ -12,10 +12,11 @@ * success, and must run at most twice. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' -const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs') +import { test } from 'vitest' + +import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild' test('shouldRetryRebuild retries only on a non-success exit', () => { assert.equal(shouldRetryRebuild(0), false) @@ -25,30 +26,39 @@ test('shouldRetryRebuild retries only on a non-success exit', () => { test('a clean first rebuild runs once and does not retry', async () => { const codes = [] + const result = await runRebuildWithRetry(attempt => { codes.push(attempt) + return Promise.resolve({ code: 0 }) }) + assert.deepEqual(codes, [0]) assert.equal(result.code, 0) }) test('a failed first rebuild retries once and succeeds', async () => { const codes = [] + const result = await runRebuildWithRetry(attempt => { codes.push(attempt) + return Promise.resolve({ code: attempt === 0 ? 1 : 0 }) }) + assert.deepEqual(codes, [0, 1]) assert.equal(result.code, 0) }) test('a rebuild that keeps failing runs at most twice and reports the failure', async () => { const codes = [] + const result = await runRebuildWithRetry(attempt => { codes.push(attempt) + return Promise.resolve({ code: 1, error: 'rebuild-failed' }) }) + assert.deepEqual(codes, [0, 1]) assert.equal(result.code, 1) assert.equal(result.error, 'rebuild-failed') diff --git a/apps/desktop/electron/update-rebuild.cjs b/apps/desktop/electron/update-rebuild.ts similarity index 92% rename from apps/desktop/electron/update-rebuild.cjs rename to apps/desktop/electron/update-rebuild.ts index ec8a948316d..a2a3581eccd 100644 --- a/apps/desktop/electron/update-rebuild.cjs +++ b/apps/desktop/electron/update-rebuild.ts @@ -1,5 +1,3 @@ -'use strict' - /** * Retry-once policy for the desktop `--build-only` rebuild during self-update. * @@ -20,10 +18,12 @@ function shouldRetryRebuild(code) { */ async function runRebuildWithRetry(rebuild) { let result = await rebuild(0) + if (shouldRetryRebuild(result.code)) { result = await rebuild(1) } + return result } -module.exports = { shouldRetryRebuild, runRebuildWithRetry } +export { runRebuildWithRetry, shouldRetryRebuild } diff --git a/apps/desktop/electron/update-relaunch.test.cjs b/apps/desktop/electron/update-relaunch.test.ts similarity index 94% rename from apps/desktop/electron/update-relaunch.test.cjs rename to apps/desktop/electron/update-relaunch.test.ts index de0a76efeec..54e42eabf9b 100644 --- a/apps/desktop/electron/update-relaunch.test.cjs +++ b/apps/desktop/electron/update-relaunch.test.ts @@ -1,8 +1,8 @@ /** - * Tests for electron/update-relaunch.cjs — the pure decision + script helpers + * Tests for electron/update-relaunch.ts — the pure decision + script helpers * behind the Linux in-app update relaunch (#45205). * - * Run with: node --test electron/update-relaunch.test.cjs + * Run with: node --test electron/update-relaunch.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * What this locks (review acceptance criteria for PR #45205): @@ -17,24 +17,25 @@ * (keep a working window) unless a non-interactive fallback applies. */ -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { execFileSync } = require('node:child_process') +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' -const { - unpackedDirName, - resolveUnpackedRelease, - decideRelaunchOutcome, - sandboxPreflight, - sandboxFallbackFromEnv, +import { test } from 'vitest' + +import { + buildRelaunchScript, collectRelaunchArgs, collectRelaunchEnv, - buildRelaunchScript, - shellQuote -} = require('./update-relaunch.cjs') + decideRelaunchOutcome, + resolveUnpackedRelease, + sandboxFallbackFromEnv, + sandboxPreflight, + shellQuote, + unpackedDirName +} from './update-relaunch' const ROOT = '/home/u/.hermes/hermes-agent' const UNPACKED = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked') @@ -91,6 +92,7 @@ test('decideRelaunchOutcome: only under-unpacked + sandbox-ok relaunches', () => // --------------------------------------------------------------------------- const fakeStat = (uid, mode) => () => ({ uid, mode }) + const throwStat = () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } @@ -150,6 +152,7 @@ test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', ( '--profile=work', // app flag — keep '--remote-debugging-port=9222' // internal — drop ] + assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/agent/42', '--profile=work']) assert.deepEqual(collectRelaunchArgs(undefined), []) }) @@ -160,16 +163,19 @@ test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt- HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', HERMES_DESKTOP_REMOTE_TOKEN: 'secret', HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes', + HERMES_DESKTOP_APP_NAME: 'HermesSandbox', ELECTRON_DISABLE_SANDBOX: '1', // sandbox opt-out — preserved PATH: '/usr/bin', // not preserved HOME: '/home/u', // not preserved UNRELATED: 'x' } + assert.deepEqual(collectRelaunchEnv(env), { HERMES_HOME: '/home/u/.hermes', HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', HERMES_DESKTOP_REMOTE_TOKEN: 'secret', HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes', + HERMES_DESKTOP_APP_NAME: 'HermesSandbox', ELECTRON_DISABLE_SANDBOX: '1' }) assert.deepEqual(collectRelaunchEnv(null), {}) @@ -207,6 +213,7 @@ test('buildRelaunchScript embeds pid/exec/args/env/cwd and is valid bash', () => // It must be syntactically valid bash (`bash -n`). Write to a temp file and lint. const tmp = path.join(os.tmpdir(), `hermes-relaunch-test-${Date.now()}.sh`) fs.writeFileSync(tmp, script) + try { execFileSync('bash', ['-n', tmp], { stdio: 'pipe' }) } finally { @@ -222,13 +229,16 @@ test('buildRelaunchScript with no args/env still lints clean', () => { env: {}, cwd: '' }) + const tmp = path.join(os.tmpdir(), `hermes-relaunch-test2-${Date.now()}.sh`) fs.writeFileSync(tmp, script) + try { execFileSync('bash', ['-n', tmp], { stdio: 'pipe' }) } finally { fs.rmSync(tmp, { force: true }) } + // exec line has no trailing args. assert.match(script, /exec '\/opt\/Hermes\/Hermes'\n/) }) diff --git a/apps/desktop/electron/update-relaunch.cjs b/apps/desktop/electron/update-relaunch.ts similarity index 89% rename from apps/desktop/electron/update-relaunch.cjs rename to apps/desktop/electron/update-relaunch.ts index 62032cde8c9..46ea789bbf6 100644 --- a/apps/desktop/electron/update-relaunch.cjs +++ b/apps/desktop/electron/update-relaunch.ts @@ -1,12 +1,10 @@ -'use strict' - /** - * update-relaunch.cjs — pure decision + script-generation helpers for the + * update-relaunch.ts — pure decision + script-generation helpers for the * Linux in-app update relaunch (#45205). * - * Extracted from main.cjs's `applyUpdatesPosixInApp` so the security- and + * Extracted from main.ts's `applyUpdatesPosixInApp` so the security- and * correctness-critical "do we relaunch, or land on a manual terminal state?" - * decision is unit-testable without booting Electron (main.cjs + * decision is unit-testable without booting Electron (main.ts * `require('electron')` at load). * * Background @@ -37,12 +35,18 @@ * the closeable manual-restart terminal state instead. */ -const path = require('node:path') +import path from 'node:path' // Map process.platform → electron-builder's `release/<dir>-unpacked` name. function unpackedDirName(platform) { - if (platform === 'darwin') return 'mac-unpacked' // not used (mac swaps bundles) - if (platform === 'win32') return 'win-unpacked' + if (platform === 'darwin') { + return 'mac-unpacked' + } // not used (mac swaps bundles) + + if (platform === 'win32') { + return 'win-unpacked' + } + return 'linux-unpacked' } @@ -56,15 +60,20 @@ function unpackedDirName(platform) { * `.../release/linux-unpacked-evil` can't masquerade as `.../release/linux-unpacked`. */ function resolveUnpackedRelease(execPath, updateRoot, platform) { - if (!execPath || !updateRoot) return null + if (!execPath || !updateRoot) { + return null + } + const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release') const unpacked = path.join(releaseDir, unpackedDirName(platform)) const normalizedExec = path.resolve(String(execPath)) // execPath must be the unpacked dir itself or a descendant of it. const withSep = unpacked.endsWith(path.sep) ? unpacked : unpacked + path.sep + if (normalizedExec === unpacked || normalizedExec.startsWith(withSep)) { return unpacked } + return null } @@ -81,8 +90,14 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) { * app. Closeable manual-restart terminal state. */ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) { - if (!underUnpacked) return 'guiSkew' - if (!sandboxOk) return 'manual' + if (!underUnpacked) { + return 'guiSkew' + } + + if (!sandboxOk) { + return 'manual' + } + return 'relaunch' } @@ -99,9 +114,13 @@ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) { * `statSync` is injectable so this is testable without a real setuid file. */ function sandboxPreflight(unpackedDir, statSync) { - if (!unpackedDir) return { ok: false, reason: 'no-unpacked-dir', path: null } + if (!unpackedDir) { + return { ok: false, reason: 'no-unpacked-dir', path: null } + } + const sandboxPath = path.join(unpackedDir, 'chrome-sandbox') let st + try { st = statSync(sandboxPath) } catch { @@ -109,15 +128,22 @@ function sandboxPreflight(unpackedDir, statSync) { // sandbox; nothing to block the relaunch. return { ok: true, reason: 'no-sandbox-helper', path: sandboxPath } } + const ownedByRoot = st.uid === 0 const hasSetuid = (st.mode & 0o4000) !== 0 + if (ownedByRoot && hasSetuid) { return { ok: true, reason: 'launchable', path: sandboxPath } } + if (!ownedByRoot && !hasSetuid) { return { ok: false, reason: 'not-root-not-setuid', path: sandboxPath } } - if (!ownedByRoot) return { ok: false, reason: 'not-root', path: sandboxPath } + + if (!ownedByRoot) { + return { ok: false, reason: 'not-root', path: sandboxPath } + } + return { ok: false, reason: 'not-setuid', path: sandboxPath } } @@ -126,7 +152,7 @@ function sandboxPreflight(unpackedDir, statSync) { * environment. The reviewer asked us to integrate with any existing * `--no-sandbox` / chrome-sandbox handling. A repo grep found NO existing * non-interactive sandbox fallback in the desktop app (the only chrome-sandbox - * reference is documentation in scripts/before-pack.cjs). The one signal that + * reference is documentation in scripts/before-pack.ts). The one signal that * DOES exist is the standard Electron escape hatch: ELECTRON_DISABLE_SANDBOX=1 * (and the equivalent `--no-sandbox` already present in the launch args). If * the user has set that, the rebuilt binary will start even with a broken @@ -137,8 +163,15 @@ function sandboxPreflight(unpackedDir, statSync) { */ function sandboxFallbackFromEnv(env, launchArgs) { const disable = String((env && env.ELECTRON_DISABLE_SANDBOX) || '').trim() - if (disable === '1' || disable.toLowerCase() === 'true') return true - if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) return true + + if (disable === '1' || disable.toLowerCase() === 'true') { + return true + } + + if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) { + return true + } + return false } @@ -176,9 +209,15 @@ const INTERNAL_ARG_PREFIXES = [ * the exec path itself; there is no entry-script arg as in a dev run). */ function collectRelaunchArgs(argv) { - if (!Array.isArray(argv)) return [] + if (!Array.isArray(argv)) { + return [] + } + return argv.filter(arg => { - if (typeof arg !== 'string' || arg.length === 0) return false + if (typeof arg !== 'string' || arg.length === 0) { + return false + } + return !INTERNAL_ARG_PREFIXES.some(prefix => prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=') ) @@ -197,13 +236,21 @@ const PRESERVED_ENV_PREFIXES = ['HERMES_DESKTOP_'] function collectRelaunchEnv(env) { const out = {} - if (!env || typeof env !== 'object') return out + + if (!env || typeof env !== 'object') { + return out + } + for (const [key, value] of Object.entries(env)) { - if (value == null) continue + if (value == null) { + continue + } + if (PRESERVED_ENV_KEYS.includes(key) || PRESERVED_ENV_PREFIXES.some(p => key.startsWith(p))) { out[key] = String(value) } } + return out } @@ -223,8 +270,10 @@ function buildRelaunchScript({ pid, execPath, args, env, cwd }) { const exports = Object.entries(env || {}) .map(([k, v]) => `export ${k}=${shellQuote(v)}`) .join('\n') + const quotedArgs = (args || []).map(shellQuote).join(' ') const cwdLine = cwd ? `cd ${shellQuote(cwd)} 2>/dev/null || true` : '' + // NOTE: `exec` replaces the watcher process with the relaunched app, so the // re-exec inherits exactly the env/cwd we set above. return `#!/bin/bash @@ -249,17 +298,17 @@ exec ${shellQuote(execPath)}${quotedArgs ? ' ' + quotedArgs : ''} ` } -module.exports = { - unpackedDirName, - resolveUnpackedRelease, - decideRelaunchOutcome, - sandboxPreflight, - sandboxFallbackFromEnv, +export { + buildRelaunchScript, collectRelaunchArgs, collectRelaunchEnv, - buildRelaunchScript, - shellQuote, + decideRelaunchOutcome, INTERNAL_ARG_PREFIXES, PRESERVED_ENV_KEYS, - PRESERVED_ENV_PREFIXES + PRESERVED_ENV_PREFIXES, + resolveUnpackedRelease, + sandboxFallbackFromEnv, + sandboxPreflight, + shellQuote, + unpackedDirName } diff --git a/apps/desktop/electron/update-remote.test.cjs b/apps/desktop/electron/update-remote.test.ts similarity index 92% rename from apps/desktop/electron/update-remote.test.cjs rename to apps/desktop/electron/update-remote.test.ts index 0dfba970138..80dc8b1dd0b 100644 --- a/apps/desktop/electron/update-remote.test.cjs +++ b/apps/desktop/electron/update-remote.test.ts @@ -1,8 +1,8 @@ /** - * Tests for electron/update-remote.cjs — the remote-detection helpers that + * Tests for electron/update-remote.ts — the remote-detection helpers that * keep passive update checks off the SSH origin for official installs. * - * Run with: node --test electron/update-remote.test.cjs + * Run with: node --test electron/update-remote.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * Why this matters: a public install can carry @@ -15,16 +15,17 @@ * never prompts and should keep the normal fetch path). */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' -const { - OFFICIAL_REPO_HTTPS_URL, - OFFICIAL_REPO_CANONICAL, +import { test } from 'vitest' + +import { canonicalGitHubRemote, + isOfficialSshRemote, isSshRemote, - isOfficialSshRemote -} = require('./update-remote.cjs') + OFFICIAL_REPO_CANONICAL, + OFFICIAL_REPO_HTTPS_URL +} from './update-remote' test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => { assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) diff --git a/apps/desktop/electron/update-remote.cjs b/apps/desktop/electron/update-remote.ts similarity index 76% rename from apps/desktop/electron/update-remote.cjs rename to apps/desktop/electron/update-remote.ts index 1e99bbe8877..1c5a6d57c74 100644 --- a/apps/desktop/electron/update-remote.cjs +++ b/apps/desktop/electron/update-remote.ts @@ -8,8 +8,8 @@ * which needs no auth and cannot prompt. Active update/apply flows are left * unchanged. * - * Extracted from main.cjs so the security-critical remote detection is unit - * testable without booting Electron (main.cjs requires('electron') at load). + * Extracted from main.ts so the security-critical remote detection is unit + * testable without booting Electron (main.ts requires('electron') at load). */ const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git' @@ -19,8 +19,12 @@ const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent' // no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo // compare equal. function canonicalGitHubRemote(url) { - if (!url) return '' + if (!url) { + return '' + } + let value = String(url).trim() + if (value.startsWith('git@github.com:')) { value = `github.com/${value.slice('git@github.com:'.length)}` } else if (value.startsWith('ssh://git@github.com/')) { @@ -28,13 +32,21 @@ function canonicalGitHubRemote(url) { } else { try { const parsed = new URL(value) - if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}` + + if (parsed.hostname && parsed.pathname) { + value = `${parsed.hostname}${parsed.pathname}` + } } catch { // Leave non-URL forms unchanged. } } + value = value.trim().replace(/\/+$/, '') - if (value.endsWith('.git')) value = value.slice(0, -4) + + if (value.endsWith('.git')) { + value = value.slice(0, -4) + } + return value.toLowerCase() } @@ -42,6 +54,7 @@ function isSshRemote(url) { const value = String(url || '') .trim() .toLowerCase() + return value.startsWith('git@') || value.startsWith('ssh://') } @@ -49,10 +62,4 @@ function isOfficialSshRemote(url) { return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL } -module.exports = { - OFFICIAL_REPO_HTTPS_URL, - OFFICIAL_REPO_CANONICAL, - canonicalGitHubRemote, - isSshRemote, - isOfficialSshRemote -} +export { canonicalGitHubRemote, isOfficialSshRemote, isSshRemote, OFFICIAL_REPO_CANONICAL, OFFICIAL_REPO_HTTPS_URL } diff --git a/apps/desktop/electron/vscode-marketplace.test.cjs b/apps/desktop/electron/vscode-marketplace.test.ts similarity index 95% rename from apps/desktop/electron/vscode-marketplace.test.cjs rename to apps/desktop/electron/vscode-marketplace.test.ts index 45169044bfa..adbdfbe6f02 100644 --- a/apps/desktop/electron/vscode-marketplace.test.cjs +++ b/apps/desktop/electron/vscode-marketplace.test.ts @@ -1,9 +1,8 @@ -'use strict' +import assert from 'node:assert' -const assert = require('node:assert') -const test = require('node:test') +import { test } from 'vitest' -const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs') +import { __testing, extractThemes, readCentralDirectory } from './vscode-marketplace' // Build a minimal zip with stored (uncompressed) entries so the test controls // the bytes exactly — exercises the central-directory reader + theme extraction @@ -72,6 +71,7 @@ test('extractThemes reads contributed color themes (resolving ./ paths)', () => themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }] } }) + const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } }) const zip = makeZip([ diff --git a/apps/desktop/electron/vscode-marketplace.cjs b/apps/desktop/electron/vscode-marketplace.ts similarity index 97% rename from apps/desktop/electron/vscode-marketplace.cjs rename to apps/desktop/electron/vscode-marketplace.ts index 55e49bc30ec..3fe737dc9cb 100644 --- a/apps/desktop/electron/vscode-marketplace.cjs +++ b/apps/desktop/electron/vscode-marketplace.ts @@ -1,5 +1,3 @@ -'use strict' - /** * VS Code Marketplace color-theme fetcher (main process). * @@ -14,8 +12,8 @@ * zip library into the desktop bundle for a feature this small. */ -const https = require('node:https') -const zlib = require('node:zlib') +import https from 'node:https' +import zlib from 'node:zlib' const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery' const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage' @@ -30,7 +28,7 @@ function request( url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS -) { +): Promise<Buffer<ArrayBuffer>> { return new Promise((resolve, reject) => { const req = https.request(url, { method, headers }, res => { const status = res.statusCode ?? 0 @@ -102,6 +100,7 @@ async function resolveExtension(id) { // IncludeCategoryAndTags | IncludeLatestVersionOnly = 914. flags: 914 }) + const extension = json?.results?.[0]?.extensions?.[0] if (!extension) { @@ -127,6 +126,7 @@ async function resolveExtension(id) { /** POST an ExtensionQuery payload and return the parsed gallery response. */ async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) { const body = JSON.stringify(payload) + const raw = await request(GALLERY_QUERY_URL, { method: 'POST', headers: { @@ -332,10 +332,6 @@ async function fetchMarketplaceThemes(id) { return { extensionId: trimmed, displayName, themes } } -module.exports = { - fetchMarketplaceThemes, - searchMarketplaceThemes, - extractThemes, - readCentralDirectory, - __testing: { themeEntryName, looksLikeIconTheme } -} +const __testing = { themeEntryName, looksLikeIconTheme } + +export { __testing, extractThemes, fetchMarketplaceThemes, readCentralDirectory, searchMarketplaceThemes } diff --git a/apps/desktop/electron/window-state.test.cjs b/apps/desktop/electron/window-state.test.ts similarity index 91% rename from apps/desktop/electron/window-state.test.cjs rename to apps/desktop/electron/window-state.test.ts index a0f68ce333c..40c8fe1798e 100644 --- a/apps/desktop/electron/window-state.test.cjs +++ b/apps/desktop/electron/window-state.test.ts @@ -4,19 +4,20 @@ * clamping, and the debounce that collapses mid-drag write storms. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' -const { - DEFAULT_WIDTH, - DEFAULT_HEIGHT, - MIN_WIDTH, - MIN_HEIGHT, - sanitizeWindowState, - onScreen, +import { test, vi } from 'vitest' + +import { computeWindowOptions, - debounce -} = require('./window-state.cjs') + debounce, + DEFAULT_HEIGHT, + DEFAULT_WIDTH, + MIN_HEIGHT, + MIN_WIDTH, + onScreen, + sanitizeWindowState +} from './window-state' // A single 1920×1080 monitor (work area trimmed for the taskbar). const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }] @@ -118,9 +119,10 @@ test('computeWindowOptions does not clamp when displays are unknown', () => { // ─── debounce ────────────────────────────────────────────────────────────── -test('debounce coalesces a burst into one trailing run', t => { - t.mock.timers.enable({ apis: ['setTimeout'] }) +test('debounce coalesces a burst into one trailing run', () => { + vi.useFakeTimers() let calls = 0 + const d = debounce(() => { calls += 1 }, 250) @@ -129,15 +131,18 @@ test('debounce coalesces a burst into one trailing run', t => { d() d() assert.equal(calls, 0) - t.mock.timers.tick(249) + vi.advanceTimersByTime(249) assert.equal(calls, 0) - t.mock.timers.tick(1) + vi.advanceTimersByTime(1) assert.equal(calls, 1) + + vi.useRealTimers() }) -test('debounce.flush runs now and cancels the pending timer', t => { - t.mock.timers.enable({ apis: ['setTimeout'] }) +test('debounce.flush runs now and cancels the pending timer', () => { + vi.useFakeTimers() let calls = 0 + const d = debounce(() => { calls += 1 }, 250) @@ -145,6 +150,8 @@ test('debounce.flush runs now and cancels the pending timer', t => { d() d.flush() assert.equal(calls, 1) - t.mock.timers.tick(1000) + vi.advanceTimersByTime(1000) assert.equal(calls, 1) + + vi.useRealTimers() }) diff --git a/apps/desktop/electron/window-state.cjs b/apps/desktop/electron/window-state.ts similarity index 84% rename from apps/desktop/electron/window-state.cjs rename to apps/desktop/electron/window-state.ts index 6157e469b24..56510e88273 100644 --- a/apps/desktop/electron/window-state.cjs +++ b/apps/desktop/electron/window-state.ts @@ -2,7 +2,7 @@ * Pure geometry helpers for window-state.json — restoring the main window's * size, position, and maximized flag across launches. Side-effect-free so the * part that actually matters (rejecting garbage + off-screen bounds) is - * unit-testable without booting Electron; main.cjs owns the file I/O and the + * unit-testable without booting Electron; main.ts owns the file I/O and the * live `screen` displays. */ @@ -21,41 +21,67 @@ const MIN_VISIBLE = 48 const finite = v => typeof v === 'number' && Number.isFinite(v) const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi)) +interface SanitizedWindowState { + width: number + height: number + isMaximized: boolean + x?: number + y?: number +} + // Parse raw JSON → clean state, or null if garbage. width/height are required // and floored; x/y survive only as a finite pair; isMaximized is strict. -function sanitizeWindowState(raw) { - if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null +function sanitizeWindowState(raw?: any): SanitizedWindowState | null { + if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) { + return null + } - const state = { + const state: SanitizedWindowState = { width: Math.max(MIN_WIDTH, Math.round(raw.width)), height: Math.max(MIN_HEIGHT, Math.round(raw.height)), isMaximized: raw.isMaximized === true } + if (finite(raw.x) && finite(raw.y)) { state.x = Math.round(raw.x) state.y = Math.round(raw.y) } + return state } // True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both // axes. `displays` is Electron's screen.getAllDisplays() shape. function onScreen(bounds, displays) { - if (!Array.isArray(displays)) return false + if (!Array.isArray(displays)) { + return false + } + return displays.some(({ workArea: a } = {}) => { - if (!a) return false + if (!a) { + return false + } + const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x) const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y) + return x >= MIN_VISIBLE && y >= MIN_VISIBLE }) } +interface WindowOptions { + width: number + height: number + x?: number + y?: number +} + // Sanitized state (or null) → BrowserWindow size/position options. Always sets // width/height, capped to the largest current display so a size saved on a // since-disconnected bigger monitor can't exceed any screen the user now has. // Sets x/y only when still on-screen; otherwise Electron centers the window. -function computeWindowOptions(state, displays) { - const opts = { +function computeWindowOptions(state, displays): WindowOptions { + const opts: WindowOptions = { width: finite(state?.width) ? state.width : DEFAULT_WIDTH, height: finite(state?.height) ? state.height : DEFAULT_HEIGHT } @@ -67,6 +93,7 @@ function computeWindowOptions(state, displays) { : m, { width: 0, height: 0 } ) + if (cap.width && cap.height) { opts.width = clamp(opts.width, MIN_WIDTH, cap.width) opts.height = clamp(opts.height, MIN_HEIGHT, cap.height) @@ -81,6 +108,7 @@ function computeWindowOptions(state, displays) { opts.x = state.x opts.y = state.y } + return opts } @@ -89,6 +117,7 @@ function computeWindowOptions(state, displays) { // cancels the pending timer — used on close, before the window is gone. function debounce(fn, delayMs) { let timer = null + const debounced = () => { clearTimeout(timer) timer = setTimeout(() => { @@ -96,22 +125,24 @@ function debounce(fn, delayMs) { fn() }, delayMs) } + debounced.flush = () => { clearTimeout(timer) timer = null fn() } + return debounced } -module.exports = { - DEFAULT_WIDTH, +export { + computeWindowOptions, + debounce, DEFAULT_HEIGHT, - MIN_WIDTH, + DEFAULT_WIDTH, MIN_HEIGHT, MIN_VISIBLE, - sanitizeWindowState, + MIN_WIDTH, onScreen, - computeWindowOptions, - debounce + sanitizeWindowState } diff --git a/apps/desktop/electron/windows-child-options.test.ts b/apps/desktop/electron/windows-child-options.test.ts new file mode 100644 index 00000000000..33fcac35778 --- /dev/null +++ b/apps/desktop/electron/windows-child-options.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { stopBackendChild } from './backend-child' +import { hiddenWindowsChildOptions } from './windows-child-options' + +test('hiddenWindowsChildOptions adds windowsHide:true on Windows when unset', () => { + assert.deepEqual(hiddenWindowsChildOptions({}, true), { windowsHide: true }) +}) + +test('hiddenWindowsChildOptions preserves an existing windowsHide:false on Windows', () => { + assert.deepEqual(hiddenWindowsChildOptions({ windowsHide: false }, true), { windowsHide: false }) +}) + +test('hiddenWindowsChildOptions preserves an existing windowsHide:true on Windows', () => { + assert.deepEqual(hiddenWindowsChildOptions({ windowsHide: true }, true), { windowsHide: true }) +}) + +test('hiddenWindowsChildOptions leaves options unchanged off Windows', () => { + assert.deepEqual(hiddenWindowsChildOptions({}, false), {}) + assert.deepEqual(hiddenWindowsChildOptions({ stdio: 'ignore' }, false), { stdio: 'ignore' }) +}) + +test('hiddenWindowsChildOptions merges windowsHide alongside other options on Windows', () => { + assert.deepEqual(hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, true), { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true + }) +}) + +test('hiddenWindowsChildOptions defaults isWindows from process.platform when omitted', () => { + const result = hiddenWindowsChildOptions({}) + const expectedHide = process.platform === 'win32' + + assert.equal(Boolean(result.windowsHide), expectedHide) +}) + +function makeChild(overrides: Partial<{ pid: number | null; killed: boolean }> = {}) { + const calls: string[] = [] + + return { + calls, + child: { + kill: (signal: string) => { + calls.push(signal) + }, + killed: overrides.killed ?? false, + pid: 'pid' in overrides ? overrides.pid : 1234 + } + } +} + +test('stopBackendChild tree-kills on Windows when the child has a pid', () => { + const { child, calls } = makeChild({ pid: 4242 }) + const treeKillCalls: number[] = [] + + stopBackendChild(child, { + forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), + isWindows: true + }) + + assert.deepEqual(treeKillCalls, [4242]) + assert.deepEqual(calls, [], 'SIGTERM must not be sent when the Windows tree-kill path is taken') +}) + +test('stopBackendChild sends SIGTERM on non-Windows platforms', () => { + const { child, calls } = makeChild({ pid: 4242 }) + const treeKillCalls: number[] = [] + + stopBackendChild(child, { + forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), + isWindows: false + }) + + assert.deepEqual(calls, ['SIGTERM']) + assert.deepEqual(treeKillCalls, [], 'tree-kill must not run off Windows') +}) + +test('stopBackendChild falls back to SIGTERM on Windows when the pid is not an integer', () => { + const { child, calls } = makeChild({ pid: null }) + const treeKillCalls: number[] = [] + + stopBackendChild(child, { + forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), + isWindows: true + }) + + assert.deepEqual(calls, ['SIGTERM']) + assert.deepEqual(treeKillCalls, []) +}) + +test('stopBackendChild is a no-op for an already-killed child', () => { + const { child, calls } = makeChild({ killed: true }) + const treeKillCalls: number[] = [] + + stopBackendChild(child, { + forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), + isWindows: true + }) + + assert.deepEqual(calls, []) + assert.deepEqual(treeKillCalls, []) +}) + +test('stopBackendChild is a no-op for a null/undefined child', () => { + const treeKillCalls: number[] = [] + + assert.doesNotThrow(() => { + stopBackendChild(null, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true }) + stopBackendChild(undefined, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true }) + }) + assert.deepEqual(treeKillCalls, []) +}) + +test('stopBackendChild swallows errors thrown by the kill strategy', () => { + const child = { + kill: () => { + throw new Error('ESRCH: no such process') + }, + killed: false, + pid: 99 + } + + assert.doesNotThrow(() => { + stopBackendChild(child, { + forceKillProcessTree: () => {}, + isWindows: false + }) + }) +}) diff --git a/apps/desktop/electron/windows-child-options.ts b/apps/desktop/electron/windows-child-options.ts new file mode 100644 index 00000000000..547136bad46 --- /dev/null +++ b/apps/desktop/electron/windows-child-options.ts @@ -0,0 +1,37 @@ +/** + * windows-child-options.ts + * + * Shared helper for opting Windows child processes (spawn/execFileSync) into + * a hidden console. Windows spawns a visible console window per child by + * default; every desktop-launched helper process (git, curl, taskkill, the + * backend itself, the bootstrap PowerShell runner, ...) needs `windowsHide: + * true` so the user doesn't see consoles flashing on screen. + * + * Extracted into its own dependency-free module (no electron import) so it + * can be unit-tested directly for both platforms without reading source + * text, and so main.ts and bootstrap-runner.ts share exactly one + * implementation instead of each defining their own copy. + */ + +import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process' + +/** + * Merge `windowsHide: true` into `options` when running on Windows, unless + * the caller already specified a `windowsHide` value (which is preserved + * as-is, including an explicit `false` for cases that intentionally want a + * visible/interactive console). No-op on non-Windows platforms. + * + * @param options - spawn/execFileSync options to (possibly) augment. + * @param isWindows - defaults to the real platform check; injectable for + * tests so both branches can be exercised without mocking process.platform. + */ +export function hiddenWindowsChildOptions( + options: any = {}, + isWindows: boolean = process.platform === 'win32' +): ExecFileSyncOptionsWithStringEncoding { + if (!isWindows || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { + return options as any + } + + return { ...options, windowsHide: true } as any +} diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs deleted file mode 100644 index c15dc3b7b50..00000000000 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ /dev/null @@ -1,116 +0,0 @@ -'use strict' - -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') - -const ELECTRON_DIR = __dirname - -function readElectronFile(name) { - return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') -} - -function requireHiddenChildOptions(source, needle) { - const match = needle instanceof RegExp ? needle.exec(source) : null - const index = needle instanceof RegExp ? (match?.index ?? -1) : source.indexOf(needle) - assert.notEqual(index, -1, `missing call site: ${needle}`) - const snippet = source.slice(index, index + 700) - assert.match( - snippet, - /hiddenWindowsChildOptions\(/, - `expected ${needle} to wrap child-process options with hiddenWindowsChildOptions` - ) -} - -test('desktop background child processes opt into hidden Windows consoles', () => { - const source = readElectronFile('main.cjs') - - assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) - - requireHiddenChildOptions(source, "execFileSync(\n 'reg'") - requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/) - requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/) - requireHiddenChildOptions(source, "execFileSync('taskkill'") - requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/) - requireHiddenChildOptions(source, "spawn('curl'") - requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/) - requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/) - requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/) - - assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, backendArgs\)/) - assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/) - assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/) - assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/) -}) - -test('desktop backend launches console python so child consoles are inherited, not pythonw', () => { - const source = readElectronFile('main.cjs') - - // The flash fix is structural: the backend runs as a console-subsystem - // python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it - // owns ONE windowless console that every descendant spawn inherits. Launching - // it as GUI-subsystem pythonw.exe is what made each child allocate (and flash) - // its own console, so the backend command must never be pythonw. - assert.doesNotMatch(source, /pythonw\.exe'\)/, 'backend must not be launched via pythonw.exe') - assert.doesNotMatch( - source, - /function getNoConsoleVenvPython\b/, - 'pythonw-conversion helper should be gone; console python is launched directly' - ) - assert.doesNotMatch( - source, - /function applyWindowsNoConsoleSpawnHints\b/, - 'pythonw spawn-hint rewriter should be gone' - ) - - // Console python restores stdout, so the port is announced on the normal - // HERMES_DASHBOARD_READY stdout line — no ready-file side channel is set. - assert.doesNotMatch(source, /readyFile: true/, 'no backend should opt into the pythonw ready-file path') - - // Both desktop backend launches must still go through hiddenWindowsChildOptions - // so the single backend console is created windowless. - requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/) - requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/) -}) - -test('desktop backend teardown tree-kills Windows backend descendants', () => { - const source = readElectronFile('main.cjs') - - const helperIndex = source.indexOf('function stopBackendChild(child)') - assert.notEqual(helperIndex, -1, 'missing backend teardown helper') - const helperSnippet = source.slice(helperIndex, helperIndex + 500) - assert.match(helperSnippet, /IS_WINDOWS && Number\.isInteger\(child\.pid\)/) - assert.match(helperSnippet, /forceKillProcessTree\(child\.pid\)/) - assert.match(helperSnippet, /child\.kill\('SIGTERM'\)/) - - const resetIndex = source.indexOf('function resetHermesConnection()') - assert.notEqual(resetIndex, -1, 'missing resetHermesConnection') - const resetSnippet = source.slice(resetIndex, resetIndex + 300) - assert.match(resetSnippet, /stopBackendChild\(hermesProcess\)/) - assert.doesNotMatch(resetSnippet, /hermesProcess\.kill\('SIGTERM'\)/) - - const quitIndex = source.indexOf("app.on('before-quit'") - assert.notEqual(quitIndex, -1, 'missing before-quit handler') - const quitSnippet = source.slice(quitIndex, quitIndex + 900) - assert.match(quitSnippet, /stopBackendChild\(hermesProcess\)/) - assert.doesNotMatch(quitSnippet, /hermesProcess\.kill\('SIGTERM'\)/) -}) - -test('intentional or interactive desktop child processes stay documented', () => { - const source = readElectronFile('main.cjs') - - assert.match(source, /windowsHide: false/) - assert.match(source, /handOffWindowsBootstrapRecovery/) - assert.match(source, /'--repair', '--branch'/) - assert.match(source, /'--update', '--branch'/) - assert.match(source, /nodePty\.spawn\(command, args/) - assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/) -}) - -test('bootstrap PowerShell runner hides Windows console children', () => { - const source = readElectronFile('bootstrap-runner.cjs') - - assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) - requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/) -}) diff --git a/apps/desktop/electron/windows-hermes-path.test.ts b/apps/desktop/electron/windows-hermes-path.test.ts new file mode 100644 index 00000000000..8600c350185 --- /dev/null +++ b/apps/desktop/electron/windows-hermes-path.test.ts @@ -0,0 +1,209 @@ +// Unit tests for the pure Windows `hermes` resolution helpers extracted from +// main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and +// unwrapWindowsVenvHermesCommand(). These pin the two Windows resolution bugs +// that caused desktop reinstall loops: +// 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the +// empty extension, or an extensionless Git-Bash `hermes` shim shadows +// the real hermes.cmd/hermes.exe. +// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install +// signal), not just the hermes.exe console-script shim, or healthy +// installs get forced into a destructive --repair. +// 3. resolveVenvHermesCommand() — must probe the venv python via +// canImportHermesCli() before trusting it, or a broken venv gets +// re-selected forever instead of falling through to bootstrap. + +import assert from 'node:assert/strict' +import path from 'node:path' + +import { test } from 'vitest' + +import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' + +test('buildPathExtCandidates: Windows tries PATHEXT extensions before the empty extension', () => { + const extensions = buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', true) + + assert.deepEqual(extensions, ['.COM', '.EXE', '.BAT', '.CMD', '']) + assert.equal(extensions[extensions.length - 1], '', 'empty extension must be last, not first') + assert.notEqual(extensions[0], '', 'the buggy empty-extension-first order must not return') +}) + +test('buildPathExtCandidates: defaults to .COM;.EXE;.BAT;.CMD when PATHEXT is unset on Windows', () => { + assert.deepEqual(buildPathExtCandidates(undefined, true), ['.COM', '.EXE', '.BAT', '.CMD', '']) +}) + +test('buildPathExtCandidates: respects a custom PATHEXT, still empty-last', () => { + assert.deepEqual(buildPathExtCandidates('.EXE;.PS1', true), ['.EXE', '.PS1', '']) +}) + +test('buildPathExtCandidates: non-Windows only tries the bare name', () => { + assert.deepEqual(buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', false), ['']) + assert.deepEqual(buildPathExtCandidates(undefined, false), ['']) +}) + +test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => { + assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main']) +}) + +test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => { + assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main']) +}) + +test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => { + assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2']) + assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2']) +}) + +function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>[2]> = {}) { + return { + isWindows: true, + isCommandScript: () => false, + fileExists: () => true, + directoryExists: () => false, + canImportHermesCli: () => true, + getVenvPython: (venvRoot: string) => `${venvRoot}/Scripts/python.exe`, + getVenvSitePackagesEntries: () => [], + buildDesktopBackendEnv: () => ({ FAKE_ENV: '1' }), + hermesHome: '/fake/hermes-home', + resolvePath: (...segments: string[]) => segments.join('/').replace(/\/+/g, '/'), + dirname: (p: string) => p.slice(0, p.lastIndexOf('/')) || '/', + basename: (p: string) => p.slice(p.lastIndexOf('/') + 1), + rememberLog: () => {}, + ...overrides + } +} + +test('resolveVenvHermesCommand: returns null off Windows', () => { + const deps = makeDeps({ isWindows: false }) + + assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null) +}) + +test('resolveVenvHermesCommand: returns null for a .cmd/.bat script command', () => { + const deps = makeDeps({ isCommandScript: () => true }) + + assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.cmd', [], deps), null) +}) + +test('resolveVenvHermesCommand: returns null when the basename is not hermes/hermes.exe', () => { + const deps = makeDeps() + + assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/python.exe', [], deps), null) +}) + +test('resolveVenvHermesCommand: returns null when the parent dir is not Scripts', () => { + const deps = makeDeps() + + assert.equal(resolveVenvHermesCommand('/root/venv/bin/hermes.exe', [], deps), null) +}) + +test('resolveVenvHermesCommand: returns null when the venv python does not exist on disk', () => { + const deps = makeDeps({ fileExists: () => false }) + + assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null) +}) + +test('resolveVenvHermesCommand: probes the venv python before trusting it (returns null on failed probe)', () => { + let probed = false + + const deps = makeDeps({ + canImportHermesCli: (python: string) => { + probed = true + assert.equal(python, '/root/venv/Scripts/python.exe') + + return false + } + }) + + const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve'], deps) + + assert.equal(probed, true, 'must probe the venv interpreter; a broken venv must not be re-selected forever') + assert.equal(result, null, 'a failed probe must fall through (return null) so the resolver reaches bootstrap') +}) + +test('resolveVenvHermesCommand: returns the resolved python backend descriptor when the probe passes', () => { + const deps = makeDeps() + const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve', '--port', '0'], deps) + + assert.ok(result, 'a passing probe must return a backend descriptor, not null') + assert.equal(result.command, '/root/venv/Scripts/python.exe') + assert.deepEqual(result.args, ['-m', 'hermes_cli.main', 'serve', '--port', '0']) + assert.equal(result.bootstrap, false) + assert.equal(result.kind, 'python') + assert.equal(result.shell, false) + assert.deepEqual(result.env, { FAKE_ENV: '1' }) +}) + +test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Scripts dir name', () => { + const deps = makeDeps() + + assert.ok(resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps)) + assert.ok(resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps)) +}) + +// ── getVenvSitePackagesEntries ───────────────────────────────────────────── + +test('getVenvSitePackagesEntries: returns Lib/site-packages on Windows when it exists', () => { + const expected = path.join('C:\\venv', 'Lib', 'site-packages') + + const result = getVenvSitePackagesEntries('C:\\venv', { + isWindows: true, + directoryExists: p => p === expected + }) + + assert.deepEqual(result, [expected]) +}) + +test('getVenvSitePackagesEntries: returns empty on Windows when site-packages does not exist', () => { + const result = getVenvSitePackagesEntries('C:\\venv', { + isWindows: true, + directoryExists: () => false + }) + + assert.deepEqual(result, []) +}) + +test('getVenvSitePackagesEntries: reads pyvenv.cfg version on POSIX and resolves lib/pythonX.Y/site-packages', () => { + const result = getVenvSitePackagesEntries('/venv', { + isWindows: false, + directoryExists: p => p === '/venv/lib/python3.12/site-packages', + readFile: () => 'version_info = 3.12.1\n' + }) + + assert.deepEqual(result, ['/venv/lib/python3.12/site-packages']) +}) + +test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg is missing', () => { + const result = getVenvSitePackagesEntries('/venv', { + isWindows: false, + directoryExists: () => true, + readFile: () => undefined + }) + + assert.deepEqual(result, []) +}) + +test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg has no version_info', () => { + const result = getVenvSitePackagesEntries('/venv', { + isWindows: false, + directoryExists: () => true, + readFile: () => 'home = /usr/bin\n' + }) + + assert.deepEqual(result, []) +}) + +test('getVenvSitePackagesEntries: returns empty on POSIX when version is present but site-packages dir is absent', () => { + const result = getVenvSitePackagesEntries('/venv', { + isWindows: false, + directoryExists: () => false, + readFile: () => 'version_info = 3.11\n' + }) + + assert.deepEqual(result, []) +}) + +test('getVenvSitePackagesEntries: returns empty for a falsy venvRoot', () => { + assert.deepEqual(getVenvSitePackagesEntries('', { isWindows: true, directoryExists: () => true }), []) + assert.deepEqual(getVenvSitePackagesEntries(null, { isWindows: true, directoryExists: () => true }), []) + assert.deepEqual(getVenvSitePackagesEntries(undefined, { isWindows: true, directoryExists: () => true }), []) +}) diff --git a/apps/desktop/electron/windows-hermes-path.ts b/apps/desktop/electron/windows-hermes-path.ts new file mode 100644 index 00000000000..a40524910ec --- /dev/null +++ b/apps/desktop/electron/windows-hermes-path.ts @@ -0,0 +1,287 @@ +/** + * windows-hermes-path.ts + * + * Pure, dependency-injected pieces of Windows `hermes` resolution pulled out + * of main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and + * unwrapWindowsVenvHermesCommand(). Each of the three functions here pins one + * of the Windows resolution bugs that caused desktop reinstall loops: + * + * 1. buildPathExtCandidates() — findOnPath() tried the empty extension + * FIRST, so an extensionless Git-Bash `hermes` shim shadowed the real + * hermes.cmd/hermes.exe; the shim then failed the --version probe and + * the desktop fell through to a spurious bootstrap/repair. The fix: + * PATHEXT extensions first, empty extension LAST. + * 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose + * --update vs the destructive --repair by checking ONLY + * venv\Scripts\hermes.exe (the console-script shim, written at the END + * of venv setup and absent in interrupted states), so it escalated to a + * full venv recreate even on healthy installs. The fix: gate on ANY + * real-install signal, not just the shim. + * 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned + * the venv python with NO runtime probe (bypassing the caller's + * --version check too), so a venv broken mid-update (e.g. missing + * python-dotenv) was re-selected forever: Retry / "Repair install" + * resolved the same dead interpreter instead of falling through to the + * bootstrap installer. The fix: probe-before-trust. + * + * Kept in a standalone ts module (no Electron imports, dependencies passed + * as parameters) so it can be unit-tested with `node --test` without + * mocking Electron or the filesystem, same pattern as backend-probes.ts and + * backend-command.ts. + */ + +import fs from 'node:fs' +import path from 'node:path' + +/** + * Build the ordered list of extensions findOnPath() should try when + * resolving a bare command name off PATH. + * + * On Windows this MUST try PATHEXT extensions (.COM;.EXE;.BAT;.CMD by + * default) BEFORE the bare/empty-extension name: a real command resolves via + * its .exe/.cmd per Windows command-resolution semantics, and an + * extensionless file (e.g. a Git-Bash shell-script shim named `hermes`) must + * not shadow `hermes.cmd`/`hermes.exe`. The empty entry is kept LAST so + * callers that already include the extension (py.exe, pwsh.exe, + * powershell.exe) still resolve. + * + * On non-Windows platforms there is no PATHEXT concept: only the bare name + * is tried. + * + * @param {string | undefined} pathext - process.env.PATHEXT (or undefined). + * @param {boolean} isWindows + * @returns {string[]} extensions to try, in order, always ending in ''. + */ +export function buildPathExtCandidates(pathext: string | undefined, isWindows: boolean): string[] { + if (!isWindows) { + return [''] + } + + return [...(pathext || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), ''] +} + +/** + * Choose the Windows bootstrap-recovery updater invocation: the gentle + * in-place --update when ANY real-install signal is present, the + * destructive --repair (full venv recreate) otherwise. + * + * haveRealInstall must be computed by the caller from ALL real-install + * signals (venv python interpreter, venv hermes shim, bootstrap-complete + * marker) — gating on just the hermes.exe console-script shim alone is the + * regression this function's callers must avoid: that shim is written at + * the END of venv setup and is absent in exactly the interrupted/quarantined + * states this recovery exists to heal. + * + * @param {boolean} haveRealInstall + * @param {string} branch + * @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main']. + */ +export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] { + return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] +} + +/** + * Resolve the site-packages directory entries for a Python venv. + * + * On Windows, venv layout is `<venvRoot>/Lib/site-packages`. + * On POSIX, it's `<venvRoot>/lib/python<version>/site-packages` where + * `<version>` (e.g. `3.12`) is read from the venv's `pyvenv.cfg` + * `version_info` field. + * + * Returns only directories that actually exist on disk. Returns an empty + * array when `venvRoot` is falsy or no matching site-packages dir is found. + * + * Extracted from main.ts so the platform branching can be tested without + * reading source text. `isWindows` and `directoryExists` are injectable; + * `readFile` defaults to `fs.readFileSync` but can be overridden for tests. + */ +export function getVenvSitePackagesEntries( + venvRoot: string | undefined | null, + opts: { + isWindows?: boolean + directoryExists?: (p: string) => boolean + readFile?: (p: string) => string | undefined + } = {} +): string[] { + const entries: string[] = [] + + if (!venvRoot) { + return entries + } + + const isWindows = opts.isWindows ?? process.platform === 'win32' + + const directoryExists = opts.directoryExists ?? ((p: string) => { + try { + return fs.statSync(p).isDirectory() + } catch { + return false + } + }) + + const readFile = opts.readFile ?? ((p: string) => { + try { + return fs.readFileSync(p, 'utf8') + } catch { + return undefined + } + }) + + if (isWindows) { + const sitePackages = path.join(venvRoot, 'Lib', 'site-packages') + + if (directoryExists(sitePackages)) { + entries.push(sitePackages) + } + + return entries + } + + const cfg = readFile(path.join(venvRoot, 'pyvenv.cfg')) + + const version = (() => { + if (!cfg) { + return null + } + + const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im) + + return match ? match[1].trim() : null + })() + + if (version) { + const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages') + + if (directoryExists(sitePackages)) { + entries.push(sitePackages) + } + } + + return entries +} + +export interface ResolveVenvHermesCommandDeps { + isWindows: boolean + isCommandScript: (command: string) => boolean + fileExists: (filePath: string) => boolean + directoryExists: (filePath: string) => boolean + canImportHermesCli: (python: string, opts?: { env?: Record<string, string> }) => boolean + getVenvPython: (venvRoot: string) => string + getVenvSitePackagesEntries: (venvRoot: string) => string[] + buildDesktopBackendEnv: (opts: { + hermesHome: string + pythonPathEntries: string[] + venvRoot: string + }) => Record<string, string> + hermesHome: string + resolvePath: (...segments: string[]) => string + dirname: (p: string) => string + basename: (p: string) => string + rememberLog?: (message: string) => void +} + +/** + * If `command` is a Windows venv `hermes`/`hermes.exe` console-script shim + * (i.e. `<venvRoot>/Scripts/hermes(.exe)`), resolve it to the underlying + * venv python invoked as `python -m hermes_cli.main <backendArgs>` — but + * ONLY after smoke-testing that interpreter with canImportHermesCli(). A + * venv whose update died mid-`pip install` still has python.exe + hermes.exe + * on disk, but the backend dies on its first import (e.g. + * ModuleNotFoundError: dotenv) before the gateway ever binds. Returning it + * unprobed also bypasses the caller's `--version` probe, so Retry/"Repair + * install" re-resolves the same broken venv forever instead of falling + * through to the bootstrap installer. + * + * Mirrors isActiveRuntimeUsable(): probes with the checkout on PYTHONPATH so + * a healthy source-tree venv passes. + * + * Returns null when `command` is not a venv hermes shim, the underlying + * python doesn't exist, or the import probe fails. Otherwise returns the + * resolved backend descriptor. + */ +export function resolveVenvHermesCommand( + command: string, + backendArgs: string[], + deps: ResolveVenvHermesCommandDeps +): { + label: string + command: string + args: string[] + bootstrap: false + env: Record<string, string> + kind: 'python' + root: string + shell: false +} | null { + const { + isWindows, + isCommandScript, + fileExists, + directoryExists, + canImportHermesCli, + getVenvPython, + getVenvSitePackagesEntries, + buildDesktopBackendEnv, + hermesHome, + resolvePath, + dirname, + basename, + rememberLog + } = deps + + if (!isWindows || !command || isCommandScript(command)) { + return null + } + + const resolved = resolvePath(String(command)) + + if (!/^hermes(?:\.exe)?$/i.test(basename(resolved))) { + return null + } + + const scriptsDir = dirname(resolved) + + if (basename(scriptsDir).toLowerCase() !== 'scripts') { + return null + } + + const venvRoot = dirname(scriptsDir) + const python = getVenvPython(venvRoot) + + if (!fileExists(python)) { + return null + } + + const root = dirname(venvRoot) + + if ( + !canImportHermesCli(python, { + env: { + PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH] + .filter((entry): entry is string => Boolean(entry)) + .join(path.delimiter) + } + }) + ) { + rememberLog?.( + `Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.` + ) + + return null + } + + return { + label: `existing Hermes Python at ${python}`, + command: python, + args: ['-m', 'hermes_cli.main', ...backendArgs], + bootstrap: false, + env: buildDesktopBackendEnv({ + hermesHome, + pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)], + venvRoot + }), + kind: 'python', + root, + shell: false + } +} diff --git a/apps/desktop/electron/windows-hermes-resolution.test.cjs b/apps/desktop/electron/windows-hermes-resolution.test.cjs deleted file mode 100644 index 40e2658a122..00000000000 --- a/apps/desktop/electron/windows-hermes-resolution.test.cjs +++ /dev/null @@ -1,84 +0,0 @@ -'use strict' - -// Regression guards for Windows `hermes` resolution in main.cjs. -// -// main.cjs has no module.exports, so these follow the repo's source-assertion -// test pattern (see windows-child-process.test.cjs). They pin the two Windows -// resolution bugs that caused desktop reinstall loops: -// 1. findOnPath() tried the empty extension FIRST, so an extensionless -// Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the -// shim then failed the --version probe and the desktop fell through to a -// spurious bootstrap/repair. -// 2. handOffWindowsBootstrapRecovery() chose --update vs the destructive -// --repair by checking ONLY venv\Scripts\hermes.exe (the console-script -// shim, written at the END of venv setup and absent in interrupted -// states), so it escalated to a full venv recreate even on healthy -// installs. -// 3. unwrapWindowsVenvHermesCommand() returned the venv python with NO -// runtime probe (bypassing the caller's --version check too), so a venv -// broken mid-update (e.g. missing python-dotenv) was re-selected forever: -// Retry / "Repair install" resolved the same dead interpreter instead of -// falling through to the bootstrap installer. - -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') - -function readMain() { - return fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8').replace(/\r\n/g, '\n') -} - -test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => { - const source = readMain() - // Fixed order: PATHEXT first, empty string LAST. - assert.match( - source, - /\(process\.env\.PATHEXT \|\| '\.COM;\.EXE;\.BAT;\.CMD'\)\.split\(';'\)\.filter\(Boolean\), ''\]/, - 'extensions array must end with the empty string, not start with it' - ) - // The buggy empty-first order must not return. - assert.doesNotMatch( - source, - /\['', \.\.\.\(process\.env\.PATHEXT/, - 'empty-extension-first order regressed: an extensionless shim can shadow hermes.cmd/.exe' - ) -}) - -test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => { - const source = readMain() - assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall') - assert.match(source, /fileExists\(venvPython\)/, 'recovery must accept the venv interpreter as a real-install signal') - assert.match( - source, - /\.hermes-bootstrap-complete/, - 'recovery must accept the bootstrap-complete marker as a real-install signal' - ) - assert.match(source, /updaterArgs = haveRealInstall \? \['--update'/, 'updaterArgs must gate on haveRealInstall') - // The old too-narrow check (only venv\Scripts\hermes.exe) must not return. - assert.doesNotMatch( - source, - /updaterArgs = fileExists\(venvHermes\) \?/, - 'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair' - ) -}) - -test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => { - const source = readMain() - const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(') - assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.cjs') - // Slice out just the function body (up to the next top-level function decl) - const fnEnd = source.indexOf('\nfunction ', fnStart + 1) - const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd) - assert.match( - body, - /canImportHermesCli\(python/, - 'unwrap must probe the venv interpreter; returning it unprobed re-selects a broken venv ' + - 'forever (Retry/Repair loop on a mid-update venv missing e.g. python-dotenv)' - ) - assert.match( - body, - /return null\s*\n\s*\}\s*\n\s*return \{/, - 'a failed probe must fall through (return null) so the resolver reaches the bootstrap rung' - ) -}) diff --git a/apps/desktop/electron/windows-user-env.test.cjs b/apps/desktop/electron/windows-user-env.test.ts similarity index 94% rename from apps/desktop/electron/windows-user-env.test.cjs rename to apps/desktop/electron/windows-user-env.test.ts index 3fee1598190..6b92650b60a 100644 --- a/apps/desktop/electron/windows-user-env.test.cjs +++ b/apps/desktop/electron/windows-user-env.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict') -const { test } = require('node:test') +import assert from 'node:assert/strict' -const { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } = require('./windows-user-env.cjs') +import { test } from 'vitest' + +import { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } from './windows-user-env' // ── parseRegQueryValue ───────────────────────────────────────────────────── @@ -42,25 +43,32 @@ test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => test('readWindowsUserEnvVar returns null off Windows without spawning', () => { let spawned = false + const exec = () => { spawned = true + return '' } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null) assert.equal(spawned, false) }) test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => { const calls = [] + const exec = (cmd, args) => { calls.push([cmd, args]) + return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n' } + const value = readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', env: { DRIVE: 'F:' }, exec }) + assert.equal(value, 'F:\\Hermes') assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]]) }) @@ -69,6 +77,7 @@ test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing) const exec = () => { throw new Error('reg exited 1') } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) }) diff --git a/apps/desktop/electron/windows-user-env.cjs b/apps/desktop/electron/windows-user-env.ts similarity index 79% rename from apps/desktop/electron/windows-user-env.cjs rename to apps/desktop/electron/windows-user-env.ts index 4bfaba1570d..890cd6f4e10 100644 --- a/apps/desktop/electron/windows-user-env.cjs +++ b/apps/desktop/electron/windows-user-env.ts @@ -1,4 +1,4 @@ -// windows-user-env.cjs +// windows-user-env.ts // // Read a User-scoped environment variable straight from the Windows registry // (HKCU\Environment). @@ -10,7 +10,7 @@ // gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading // the live registry value closes the gap. See #45471. -const { execFileSync } = require('node:child_process') +import { execFileSync } from 'node:child_process' // Parse the output of `reg query HKCU\Environment /v <name>`, which looks like: // @@ -20,15 +20,21 @@ const { execFileSync } = require('node:child_process') // Returns the raw value string (spaces inside the value preserved), or null when // the requested value line isn't present. function parseRegQueryValue(stdout, name) { - if (!stdout || !name) return null + if (!stdout || !name) { + return null + } + const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/ + for (const rawLine of String(stdout).split(/\r?\n/)) { const line = rawLine.trim() const match = line.match(typePattern) + if (match && match[1].toLowerCase() === name.toLowerCase()) { return match[2] } } + return null } @@ -36,9 +42,13 @@ function parseRegQueryValue(stdout, name) { // unexpanded references; plain REG_SZ paths have none, so this is a no-op for // the common F:\... case. Unknown references are left verbatim. function expandWindowsEnvRefs(value, env = process.env) { - if (!value) return value + if (!value) { + return value + } + return value.replace(/%([^%]+)%/g, (whole, name) => { const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase()) + return key != null && env[key] != null ? env[key] : whole }) } @@ -46,9 +56,24 @@ function expandWindowsEnvRefs(value, env = process.env) { // Read a User-scoped env var from HKCU\Environment. Windows-only: returns null // off-Windows (without spawning), on any spawn error, when `reg` exits non-zero // (the value doesn't exist), or when the value is empty. -function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync } = {}) { - if (platform !== 'win32' || !name) return null +function readWindowsUserEnvVar( + name, + { + platform = process.platform, + env = process.env, + exec = execFileSync + }: { + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv + exec?: typeof execFileSync | ((file?: string, args?: any) => string) + } = {} +) { + if (platform !== 'win32' || !name) { + return null + } + let stdout + try { stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], { encoding: 'utf8', @@ -59,14 +84,16 @@ function readWindowsUserEnvVar(name, { platform = process.platform, env = proces // `reg` missing, or value absent (reg exits 1) — caller falls back. return null } + const raw = parseRegQueryValue(stdout, name) - if (raw == null) return null + + if (raw == null) { + return null + } + const expanded = expandWindowsEnvRefs(raw, env).trim() + return expanded || null } -module.exports = { - expandWindowsEnvRefs, - parseRegQueryValue, - readWindowsUserEnvVar -} +export { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } diff --git a/apps/desktop/electron/workspace-cwd.test.cjs b/apps/desktop/electron/workspace-cwd.test.ts similarity index 77% rename from apps/desktop/electron/workspace-cwd.test.cjs rename to apps/desktop/electron/workspace-cwd.test.ts index 85a044ab3be..1377b9f7d78 100644 --- a/apps/desktop/electron/workspace-cwd.test.cjs +++ b/apps/desktop/electron/workspace-cwd.test.ts @@ -1,14 +1,15 @@ /** - * Tests for electron/workspace-cwd.cjs. + * Tests for electron/workspace-cwd.ts. * - * Run with: node --test electron/workspace-cwd.test.cjs + * Run with: node --test electron/workspace-cwd.test.ts */ -const test = require('node:test') -const assert = require('node:assert/strict') -const path = require('node:path') +import assert from 'node:assert/strict' +import path from 'node:path' -const { isPackagedInstallPath } = require('./workspace-cwd.cjs') +import { test } from 'vitest' + +import { isPackagedInstallPath } from './workspace-cwd' const installRoot = path.resolve('/opt/Hermes') diff --git a/apps/desktop/electron/workspace-cwd.cjs b/apps/desktop/electron/workspace-cwd.ts similarity index 78% rename from apps/desktop/electron/workspace-cwd.cjs rename to apps/desktop/electron/workspace-cwd.ts index bb5da777148..79ea87bee43 100644 --- a/apps/desktop/electron/workspace-cwd.cjs +++ b/apps/desktop/electron/workspace-cwd.ts @@ -1,7 +1,7 @@ -const path = require('node:path') +import path from 'node:path' /** True when `dir` lives inside a packaged app bundle / install tree. */ -function isPackagedInstallPath(dir, { installRoots, isPackaged }) { +function isPackagedInstallPath(dir, { installRoots, isPackaged }: { installRoots: string[]; isPackaged: boolean }) { if (!isPackaged || !dir) { return false } @@ -21,7 +21,7 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) { return true } - const rel = path.relative(root, resolved) + const rel = path.relative(root, resolved) as any if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { return true @@ -31,4 +31,4 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) { return false } -module.exports = { isPackagedInstallPath } +export { isPackagedInstallPath } diff --git a/apps/desktop/electron/wsl-clipboard-image.test.cjs b/apps/desktop/electron/wsl-clipboard-image.test.ts similarity index 94% rename from apps/desktop/electron/wsl-clipboard-image.test.cjs rename to apps/desktop/electron/wsl-clipboard-image.test.ts index 343adc1f6d6..311d11ca7da 100644 --- a/apps/desktop/electron/wsl-clipboard-image.test.cjs +++ b/apps/desktop/electron/wsl-clipboard-image.test.ts @@ -1,12 +1,13 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' -const { +import { test } from 'vitest' + +import { decodeClipboardImageBase64, encodePowerShellCommand, powershellCandidates, readWslWindowsClipboardImage -} = require('./wsl-clipboard-image.cjs') +} from './wsl-clipboard-image' const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) @@ -49,10 +50,12 @@ test('decodeClipboardImageBase64 rejects base64 without a PNG signature', () => test('readWslWindowsClipboardImage decodes the first candidate that returns a PNG', () => { const png = fakePngBuffer() const calls = [] - const exec = (cmd, args) => { + + const exec = ((cmd, args) => { calls.push({ cmd, args }) + return png.toString('base64') - } + }) as any const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] }) assert.ok(result && result.equals(png)) @@ -65,15 +68,18 @@ test('readWslWindowsClipboardImage decodes the first candidate that returns a PN test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => { let count = 0 - const exec = () => { + + const exec = (() => { count += 1 + return '' - } + }) as any const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] }) + assert.equal(result, null) // Empty stdout means "no image on the clipboard" — don't probe further candidates. assert.equal(count, 1) @@ -82,18 +88,22 @@ test('readWslWindowsClipboardImage returns null and stops when stdout is empty ( test('readWslWindowsClipboardImage falls through to the next candidate when one throws', () => { const png = fakePngBuffer() const seen = [] + const exec = cmd => { seen.push(cmd) + if (cmd === 'powershell.exe') { throw Object.assign(new Error('not found'), { code: 'ENOENT' }) } - return png.toString('base64') + + return png.toString('base64') as any } const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] }) + assert.ok(result && result.equals(png)) assert.deepEqual(seen, ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']) }) diff --git a/apps/desktop/electron/wsl-clipboard-image.cjs b/apps/desktop/electron/wsl-clipboard-image.ts similarity index 86% rename from apps/desktop/electron/wsl-clipboard-image.cjs rename to apps/desktop/electron/wsl-clipboard-image.ts index c81fe7b2a60..2859f389c02 100644 --- a/apps/desktop/electron/wsl-clipboard-image.cjs +++ b/apps/desktop/electron/wsl-clipboard-image.ts @@ -1,7 +1,7 @@ // Pull a Windows-host clipboard image from inside WSL2 via PowerShell (WSLg // bridges text but not images). Returns PNG bytes or null; exec injectable. -const { execFileSync } = require('node:child_process') +import { execFileSync } from 'node:child_process' // STA is mandatory: System.Windows.Forms.Clipboard throws ThreadStateException // off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG @@ -33,9 +33,13 @@ function powershellCandidates() { function decodeClipboardImageBase64(stdout) { const b64 = String(stdout || '').trim() - if (!b64) return null + + if (!b64) { + return null + } let buffer + try { buffer = Buffer.from(b64, 'base64') } catch { @@ -44,6 +48,7 @@ function decodeClipboardImageBase64(stdout) { // Guard against partial / garbage output: require a real PNG signature. const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { return null } @@ -54,7 +59,10 @@ function decodeClipboardImageBase64(stdout) { // Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or // null when there's no image, PowerShell is unreachable, or output is invalid. // Linux-only by contract (caller gates on IS_WSL); never throws. -function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() } = {}) { +function readWslWindowsClipboardImage({ + exec = execFileSync, + candidates = powershellCandidates() +}: { exec?: typeof execFileSync; candidates?: string[] } = {}) { const encoded = encodePowerShellCommand(PS_SCRIPT) for (const ps of candidates) { @@ -72,10 +80,17 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers stdio: ['ignore', 'pipe', 'ignore'] } ) + const decoded = decodeClipboardImageBase64(stdout) - if (decoded) return decoded + + if (decoded) { + return decoded + } + // Empty stdout = no image on the clipboard; stop, don't try fallbacks. - if (String(stdout || '').trim() === '') return null + if (String(stdout || '').trim() === '') { + return null + } } catch { // This powershell.exe candidate is missing/failed — try the next one. } @@ -84,9 +99,4 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers return null } -module.exports = { - decodeClipboardImageBase64, - encodePowerShellCommand, - powershellCandidates, - readWslWindowsClipboardImage -} +export { decodeClipboardImageBase64, encodePowerShellCommand, powershellCandidates, readWslWindowsClipboardImage } diff --git a/apps/desktop/electron/wsl-path-bridge.test.ts b/apps/desktop/electron/wsl-path-bridge.test.ts new file mode 100644 index 00000000000..79505a1d394 --- /dev/null +++ b/apps/desktop/electron/wsl-path-bridge.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import { test } from 'vitest' + +import { parseDefaultDistro, resolvePickerDefaultPath, wslPosixToWindowsAccessible } from './wsl-path-bridge' + +test('parseDefaultDistro reads the first distro from clean utf-8 output', () => { + assert.equal(parseDefaultDistro('Ubuntu\nDebian\n'), 'Ubuntu') +}) + +test('parseDefaultDistro survives UTF-16LE NUL bytes older wsl.exe leaves in (WSL#4607)', () => { + // `wsl.exe -l -q` emits UTF-16LE without a BOM on builds that ignore + // WSL_UTF8; decoded as utf8 that reads as NUL-interleaved text. + const utf16ish = '\0U\0b\0u\0n\0t\0u\0\r\0\n\0D\0e\0b\0i\0a\0n\0' + assert.equal(parseDefaultDistro(utf16ish), 'Ubuntu') +}) + +test('parseDefaultDistro strips the default-marker and blank lines', () => { + assert.equal(parseDefaultDistro('\n* Ubuntu\nDebian\n'), 'Ubuntu') + assert.equal(parseDefaultDistro(' \n\n'), null) +}) + +test('wslPosixToWindowsAccessible maps a drvfs mount to its Windows drive', () => { + assert.equal(wslPosixToWindowsAccessible('/mnt/c/Users/alex', 'Ubuntu'), 'C:\\Users\\alex') + assert.equal(wslPosixToWindowsAccessible('/mnt/d', 'Ubuntu'), 'D:\\') +}) + +test('wslPosixToWindowsAccessible maps an in-distro POSIX path to a UNC share', () => { + assert.equal(wslPosixToWindowsAccessible('/home/alex/proj', 'Ubuntu'), '\\\\wsl.localhost\\Ubuntu\\home\\alex\\proj') +}) + +test('wslPosixToWindowsAccessible leaves non-absolute / already-Windows paths alone', () => { + assert.equal(wslPosixToWindowsAccessible('C:\\Users\\alex', 'Ubuntu'), 'C:\\Users\\alex') + assert.equal(wslPosixToWindowsAccessible('relative/dir', 'Ubuntu'), 'relative/dir') +}) + +test('resolvePickerDefaultPath bridges a WSL cwd but passes Windows paths and empties through', () => { + assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu'), '\\\\wsl.localhost\\Ubuntu\\home\\alex') + assert.equal(resolvePickerDefaultPath('C:\\proj', 'Ubuntu'), 'C:\\proj') + assert.equal(resolvePickerDefaultPath(undefined, 'Ubuntu'), undefined) +}) diff --git a/apps/desktop/electron/wsl-path-bridge.ts b/apps/desktop/electron/wsl-path-bridge.ts new file mode 100644 index 00000000000..0b14604df67 --- /dev/null +++ b/apps/desktop/electron/wsl-path-bridge.ts @@ -0,0 +1,136 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' + +// Bridges WSL/POSIX paths into forms the *Windows host* can open, for the case +// where the desktop UI runs on Windows and the gateway runs inside WSL (remote +// mode). Only the Windows-side direction lives here: the native folder dialog's +// defaultPath and the fs read path. The reverse (whatever path the backend +// receives → POSIX) is handled once, gateway-side, in +// hermes_constants.translate_cwd_for_wsl_backend, so it stays picker-agnostic. + +const IS_WINDOWS = process.platform === 'win32' +const WIN_DRIVE_RE = /^([A-Za-z]):[\\/]/ +// `/mnt/c` and `/mnt/c/...` (drvfs default automount root). +const WSL_MOUNT_RE = /^\/mnt\/([a-z])(?:\/(.*))?$/i + +let cachedDistro: null | string = null +let cachedUncBase: null | string = null + +/** + * Pick the default distro from `wsl.exe -l -q` output. + * + * `wsl.exe` emits UTF-16LE without a BOM unless `WSL_UTF8=1` (WSL >= 0.64), so + * older builds leave NUL bytes between characters even when we ask for utf8 — + * strip them defensively before splitting. The default distro is the first + * (`*`-marked, decoration removed by `-q`) entry. See microsoft/WSL#4607. + */ +export function parseDefaultDistro(raw: string): null | string { + return ( + String(raw || '') + .replace(/\0/g, '') + .split(/\r?\n/) + .map(line => line.replace(/^\*?\s*/, '').trim()) + .find(Boolean) || null + ) +} + +/** Default WSL distro name (cached). Falls back to `Ubuntu`. */ +export function resolveDefaultWslDistro(): string { + if (cachedDistro) { + return cachedDistro + } + + if (!IS_WINDOWS) { + cachedDistro = 'Ubuntu' + + return cachedDistro + } + + try { + const out = execFileSync('wsl.exe', ['-l', '-q'], { + encoding: 'utf8', + env: { ...process.env, WSL_UTF8: '1' }, + timeout: 2000, + windowsHide: true + }) + cachedDistro = parseDefaultDistro(out) || 'Ubuntu' + } catch { + cachedDistro = 'Ubuntu' + } + + return cachedDistro +} + +// `\\wsl.localhost\<distro>` (Win11 / Win10 >= 21364) with a `\\wsl$\<distro>` +// fallback for older builds. Probed once; defaults to wsl.localhost. +function wslUncBase(distro: string): string { + if (cachedUncBase) { + return cachedUncBase + } + + const modern = `\\\\wsl.localhost\\${distro}` + const legacy = `\\\\wsl$\\${distro}` + + try { + if (!fs.existsSync(modern) && fs.existsSync(legacy)) { + cachedUncBase = legacy + + return cachedUncBase + } + } catch { + // Network-path probe failed — prefer the modern form. + } + + cachedUncBase = modern + + return cachedUncBase +} + +/** + * A WSL/POSIX path → a path the Windows host can open: `/mnt/c/...` → `C:\...` + * (drvfs mount), any other absolute POSIX path → `\\wsl.localhost\<distro>\...`. + * Non-absolute or already-Windows paths pass through. + */ +export function wslPosixToWindowsAccessible(posixPath: string, distro: string = resolveDefaultWslDistro()): string { + const value = String(posixPath || '').trim() + const normalized = value.replace(/\\/g, '/') + + if (!normalized.startsWith('/')) { + return value + } + + const mount = normalized.match(WSL_MOUNT_RE) + + if (mount) { + const tail = (mount[2] || '').replace(/\//g, '\\') + + return tail ? `${mount[1].toUpperCase()}:\\${tail}` : `${mount[1].toUpperCase()}:\\` + } + + const relative = normalized.replace(/^\/+/, '').replace(/\//g, '\\') + + return `${wslUncBase(distro)}\\${relative}` +} + +/** Native folder dialog `defaultPath`: open a WSL cwd in the Windows picker. */ +export function resolvePickerDefaultPath( + defaultPath: string | undefined, + distro: string = resolveDefaultWslDistro() +): string | undefined { + if (!defaultPath) { + return undefined + } + + const value = String(defaultPath).trim() + + return value.startsWith('/') && !WIN_DRIVE_RE.test(value) ? wslPosixToWindowsAccessible(value, distro) : defaultPath +} + +/** fs read path: on Windows, make a WSL cwd readable via its UNC / drive form. */ +export function resolveLocalReadPath(dirPath: string, distro: string = resolveDefaultWslDistro()): string { + const value = String(dirPath || '').trim() + + return IS_WINDOWS && value.startsWith('/') && !WIN_DRIVE_RE.test(value) + ? wslPosixToWindowsAccessible(value, distro) + : value +} diff --git a/apps/desktop/electron/zoom.cjs b/apps/desktop/electron/zoom.cjs deleted file mode 100644 index 41477f41b42..00000000000 --- a/apps/desktop/electron/zoom.cjs +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Pure helpers for window zoom. The main process owns webContents.setZoomLevel, - * so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel - * through this one clamped scale. Percent is the user-facing unit (100 = the - * default size); Chromium's internal unit is the zoom level, where - * factor = 1.2 ^ level. - */ - -const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' - -const ZOOM_FACTOR_BASE = 1.2 -const MIN_ZOOM_LEVEL = -9 -const MAX_ZOOM_LEVEL = 9 - -function clampZoomLevel(value) { - if (!Number.isFinite(value)) return 0 - return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) -} - -function zoomLevelToPercent(level) { - return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100) -} - -function percentToZoomLevel(percent) { - if (!Number.isFinite(percent) || percent <= 0) return 0 - return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) -} - -module.exports = { - ZOOM_STORAGE_KEY, - clampZoomLevel, - percentToZoomLevel, - zoomLevelToPercent -} diff --git a/apps/desktop/electron/zoom.test.cjs b/apps/desktop/electron/zoom.test.cjs deleted file mode 100644 index da104a52630..00000000000 --- a/apps/desktop/electron/zoom.test.cjs +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Unit tests for the pure zoom helpers: clamping garbage input, the - * percent <-> zoom-level conversion the settings UI relies on, and the - * roundtrip stability of the preset percentages. - */ - -const test = require('node:test') -const assert = require('node:assert/strict') - -const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs') - -test('storage key stays stable so persisted zoom survives upgrades', () => { - assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel') -}) - -test('clampZoomLevel rejects garbage and enforces bounds', () => { - assert.equal(clampZoomLevel(NaN), 0) - assert.equal(clampZoomLevel(Infinity), 0) - assert.equal(clampZoomLevel(undefined), 0) - assert.equal(clampZoomLevel('2'), 0) - assert.equal(clampZoomLevel(0.3), 0.3) - assert.equal(clampZoomLevel(-42), -9) - assert.equal(clampZoomLevel(42), 9) -}) - -test('level 0 is exactly 100 percent', () => { - assert.equal(zoomLevelToPercent(0), 100) - assert.equal(percentToZoomLevel(100), 0) -}) - -test('percentToZoomLevel rejects garbage', () => { - assert.equal(percentToZoomLevel(NaN), 0) - assert.equal(percentToZoomLevel(0), 0) - assert.equal(percentToZoomLevel(-50), 0) - assert.equal(percentToZoomLevel(undefined), 0) -}) - -test('preset percentages roundtrip within rounding', () => { - for (const percent of [90, 100, 110, 125, 150, 175]) { - assert.equal(zoomLevelToPercent(percentToZoomLevel(percent)), percent) - } -}) - -test('conversion is monotonic across the preset range', () => { - const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel) - for (let i = 1; i < levels.length; i++) { - assert.ok(levels[i] > levels[i - 1]) - } -}) - -test('extreme percentages clamp to the level bounds', () => { - assert.equal(percentToZoomLevel(1), -9) - assert.equal(percentToZoomLevel(1_000_000), 9) -}) diff --git a/apps/desktop/electron/zoom.test.ts b/apps/desktop/electron/zoom.test.ts new file mode 100644 index 00000000000..d0a1517d38e --- /dev/null +++ b/apps/desktop/electron/zoom.test.ts @@ -0,0 +1,160 @@ +/** + * Unit tests for the pure zoom helpers: clamping garbage input, the + * percent <-> zoom-level conversion the settings UI relies on, and the + * roundtrip stability of the preset percentages. + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + applyZoomLevel, + clampZoomLevel, + installZoomReassertOnWindowEvents, + percentToZoomLevel, + ZOOM_REASSERT_WINDOW_EVENTS, + ZOOM_STORAGE_KEY, + zoomLevelToPercent, + zoomWiringForWindowKind +} from './zoom' + +test('storage key stays stable so persisted zoom survives upgrades', () => { + assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel') +}) + +test('clampZoomLevel rejects garbage and enforces bounds', () => { + assert.equal(clampZoomLevel(NaN), 0) + assert.equal(clampZoomLevel(Infinity), 0) + assert.equal(clampZoomLevel(undefined), 0) + assert.equal(clampZoomLevel('2'), 0) + assert.equal(clampZoomLevel(0.3), 0.3) + assert.equal(clampZoomLevel(-42), -9) + assert.equal(clampZoomLevel(42), 9) +}) + +test('level 0 is exactly 100 percent', () => { + assert.equal(zoomLevelToPercent(0), 100) + assert.equal(percentToZoomLevel(100), 0) +}) + +test('percentToZoomLevel rejects garbage', () => { + assert.equal(percentToZoomLevel(NaN), 0) + assert.equal(percentToZoomLevel(0), 0) + assert.equal(percentToZoomLevel(-50), 0) + assert.equal(percentToZoomLevel(undefined), 0) +}) + +test('preset percentages roundtrip within rounding', () => { + for (const percent of [90, 100, 110, 125, 150, 175]) { + assert.equal(zoomLevelToPercent(percentToZoomLevel(percent)), percent) + } +}) + +test('conversion is monotonic across the preset range', () => { + const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel) + + for (let i = 1; i < levels.length; i++) { + assert.ok(levels[i] > levels[i - 1]) + } +}) + +test('extreme percentages clamp to the level bounds', () => { + assert.equal(percentToZoomLevel(1), -9) + assert.equal(percentToZoomLevel(1_000_000), 9) +}) + +test('installZoomReassertOnWindowEvents wires show and restore', () => { + const handlers = new Map() + + const win = { + isDestroyed: () => false, + on(event, listener) { + handlers.set(event, listener) + } + } + + let calls = 0 + installZoomReassertOnWindowEvents(win, () => { + calls += 1 + }) + + assert.deepEqual([...handlers.keys()], [...ZOOM_REASSERT_WINDOW_EVENTS]) + handlers.get('show')() + handlers.get('restore')() + assert.equal(calls, 2) +}) + +test('installZoomReassertOnWindowEvents skips destroyed windows', () => { + const handlers = new Map() + let destroyed = false + + const win = { + isDestroyed: () => destroyed, + on(event, listener) { + handlers.set(event, listener) + } + } + + let calls = 0 + installZoomReassertOnWindowEvents(win, () => { + calls += 1 + }) + destroyed = true + handlers.get('show')() + assert.equal(calls, 0) +}) + +// Zoom-wiring contract: chat windows keep global UI zoom, the pet overlay +// opts out. Tested via the extracted config — no source-text regex. +test('chat windows opt into zoom', () => { + assert.deepEqual(zoomWiringForWindowKind('chat'), { zoom: true }) +}) + +test('pet overlay opts out of zoom', () => { + assert.deepEqual(zoomWiringForWindowKind('petOverlay'), { zoom: false }) +}) + +test('unknown window kinds default to chat (zoom enabled)', () => { + assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true }) + assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true }) +}) + +// The UI Scale settings control drifts out of sync after a restart when zoom +// is applied to the window but the renderer is never told: its $zoomPercent +// store (see store/zoom.ts) only updates from zoom.get() (once, on load) and +// 'hermes:zoom:changed' events. applyZoomLevel is the single funnel every zoom +// path (user set, restore-on-load, lifecycle re-assert) shares, so applying a +// level always notifies — the regression can't come back by forgetting a send. +function fakeWebContents() { + const calls: Array<[string, ...unknown[]]> = [] + + return { + calls, + setZoomLevel: (level: number) => calls.push(['setZoomLevel', level]), + send: (channel: string, payload: unknown) => calls.push(['send', channel, payload]) + } +} + +test('applyZoomLevel applies the level then notifies the renderer', () => { + const wc = fakeWebContents() + const applied = applyZoomLevel(wc, 3) + + assert.equal(applied, 3) + assert.deepEqual(wc.calls, [ + ['setZoomLevel', 3], + ['send', 'hermes:zoom:changed', { level: 3, percent: zoomLevelToPercent(3) }] + ]) +}) + +test('applyZoomLevel clamps garbage before applying and notifying', () => { + const wc = fakeWebContents() + const applied = applyZoomLevel(wc, 999) + const clamped = clampZoomLevel(999) + + assert.equal(applied, clamped) + assert.deepEqual(wc.calls, [ + ['setZoomLevel', clamped], + ['send', 'hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }] + ]) +}) diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts new file mode 100644 index 00000000000..baf8ff48b70 --- /dev/null +++ b/apps/desktop/electron/zoom.ts @@ -0,0 +1,86 @@ +/** + * Pure helpers for window zoom. The main process owns webContents.setZoomLevel, + * so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel + * through this one clamped scale. Percent is the user-facing unit (100 = the + * default size); Chromium's internal unit is the zoom level, where + * factor = 1.2 ^ level. + */ + +export const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' + +const ZOOM_FACTOR_BASE = 1.2 +const MIN_ZOOM_LEVEL = -9 +const MAX_ZOOM_LEVEL = 9 + +export function clampZoomLevel(value) { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) +} + +export function zoomLevelToPercent(level) { + return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100) +} + +export function percentToZoomLevel(percent) { + if (!Number.isFinite(percent) || percent <= 0) { + return 0 + } + + return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) +} + +/** + * Apply a clamped zoom level to a webContents AND notify the renderer, in that + * order. Every path that changes zoom (user action, restore-on-load, lifecycle + * re-assert) funnels through here so the settings UI Scale control can never + * drift from the actually-applied level — the bug where restore set the level + * but forgot to emit 'hermes:zoom:changed', leaving the control stuck at 100%. + * Returns the clamped level so callers can persist it. + */ +export function applyZoomLevel(webContents, level) { + const clamped = clampZoomLevel(level) + webContents.setZoomLevel(clamped) + webContents.send('hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }) + + return clamped +} + +// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized +// and restored. Re-apply the persisted level on these lifecycle transitions. +export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore'] + +export function installZoomReassertOnWindowEvents(win, reassert) { + if (!win?.on) { + return + } + + for (const event of ZOOM_REASSERT_WINDOW_EVENTS) { + win.on(event, () => { + if (win.isDestroyed?.()) { + return + } + + reassert() + }) + } +} + +/** + * Zoom-wiring decision per window kind. Chat windows (main + session) keep + * global UI zoom; the pet overlay opts out because it sizes its own OS window + * to the sprite and inheriting zoom would crop it. + * + * Extracted so the "pet opts out, everything else opts in" contract is + * unit-testable without booting a BrowserWindow or reading source. + */ +export const ZOOM_WINDOW_CONFIG = { + chat: { zoom: true }, + petOverlay: { zoom: false } +} as const + +export function zoomWiringForWindowKind(kind) { + return ZOOM_WINDOW_CONFIG[kind] ?? ZOOM_WINDOW_CONFIG.chat +} diff --git a/apps/desktop/eslint.config.mjs b/apps/desktop/eslint.config.mjs index 069a0056bbb..2a5a925e8ff 100644 --- a/apps/desktop/eslint.config.mjs +++ b/apps/desktop/eslint.config.mjs @@ -105,15 +105,18 @@ export default [ } }, { - files: ['**/*.js', '**/*.cjs'], + files: ['**/*.js', '**/*.cjs', '**/*.mjs'], ignores: ['**/node_modules/**', '**/dist/**'], languageOptions: { ecmaVersion: 'latest', globals: { ...globals.node }, - sourceType: 'commonjs' + sourceType: 'module' } }, { - ignores: ['*.config.*'] + files: ['**/*.test.tsx'], + rules: { + 'no-restricted-globals': ['warn', 'document'] + } } ] diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c3daba00e2b..0a4fd7f4e9c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -6,22 +6,23 @@ "description": "Native desktop shell for Hermes Agent.", "author": "Nous Research", "type": "module", - "main": "electron/main.cjs", + "main": "dist/electron-main.mjs", "engines": { "node": "^20.19.0 || >=22.12.0" }, "scripts": { "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"", "dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev", - "dev:renderer": "node scripts/assert-root-install.cjs && vite --host 127.0.0.1 --port 5174", - "dev:electron": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", - "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", - "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174", + "dev:electron": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", + "profile:main:cpu": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", - "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", - "postbuild": "node scripts/assert-dist-built.cjs", - "prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs", - "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs", + "prebuild": "tsc -b . --clean", + "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs", + "postbuild": "node scripts/assert-dist-built.mjs", + "prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs", + "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs", "pack": "npm run build && npm run builder -- --dir", "dist": "npm run build && npm run builder", "dist:mac": "npm run build && npm run builder -- --mac", @@ -37,14 +38,16 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/zoom.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs", - "typecheck": "tsc -p . --noEmit", + "typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", - "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'", + "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'", "fix": "npm run lint:fix && npm run fmt", - "test:ui": "vitest run --environment jsdom", - "preview": "node scripts/assert-root-install.cjs && vite preview --host 127.0.0.1 --port 4174" + "test:ui": "vitest run --project ui", + "test:desktop:platforms": "vitest run --project electron", + "test": "vitest run", + "preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174", + "check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build" }, "dependencies": { "@assistant-ui/react": "^0.12.28", @@ -117,12 +120,13 @@ "web-haptics": "^0.0.6" }, "devDependencies": { + "@electron/rebuild": "^4.0.6", "@eslint/js": "^9.39.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", "@types/d3-force": "^3.0.10", "@types/hast": "^3.0.4", - "@types/node": "^24.13.2", + "@types/node": "^22.20.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.59.1", @@ -132,6 +136,7 @@ "cross-env": "^10.1.0", "electron": "40.10.2", "electron-builder": "^26.8.1", + "esbuild": "^0.28.1", "eslint": "^9.39.4", "eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-react": "^7.37.5", @@ -141,6 +146,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.3", "rcedit": "^5.0.2", + "tsx": "^4.22.4", "typescript": "^6.0.3", "vite": "^8.0.10", "vitest": "^4.1.5", @@ -167,29 +173,24 @@ "files": [ "dist/**", "assets/**", - "electron/**", "public/**", "package.json" ], - "beforeBuild": "scripts/before-build.cjs", - "beforePack": "scripts/before-pack.cjs", - "afterPack": "scripts/after-pack.cjs", + "beforeBuild": "scripts/before-build.mjs", + "beforePack": "scripts/before-pack.mjs", + "afterPack": "scripts/after-pack.mjs", "extraResources": [ { "from": "build/install-stamp.json", "to": "install-stamp.json" }, - { - "from": "build/native-deps", - "to": "native-deps" - }, { "from": "assets/icon.ico", "to": "icon.ico" } ], "asar": true, - "afterSign": "scripts/notarize.cjs", + "afterSign": "scripts/notarize.mjs", "asarUnpack": [ "**/*.node", "**/prebuilds/**", diff --git a/apps/desktop/scripts/after-pack.cjs b/apps/desktop/scripts/after-pack.mjs similarity index 81% rename from apps/desktop/scripts/after-pack.cjs rename to apps/desktop/scripts/after-pack.mjs index f81262d28ae..509cbb42481 100644 --- a/apps/desktop/scripts/after-pack.cjs +++ b/apps/desktop/scripts/after-pack.mjs @@ -1,8 +1,8 @@ /** - * after-pack.cjs — electron-builder afterPack hook. + * after-pack.mjs — electron-builder afterPack hook. * * Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via - * rcedit (delegated to set-exe-identity.cjs). This runs for EVERY packed build + * rcedit (delegated to set-exe-identity.mjs). This runs for EVERY packed build * — first install, `hermes desktop`, the installer's --update rebuild, and a * dev's manual `npm run pack` — so the branded exe can never silently revert * to the stock "Electron" icon/name (the bug when the stamp lived only in @@ -19,18 +19,18 @@ * - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes') */ -const path = require('node:path') +import path from 'node:path' -const { stampExeIdentity } = require('./set-exe-identity.cjs') +import { stampExeIdentity } from './set-exe-identity.mjs' -exports.default = async function afterPack(context) { +export default async function afterPack(context) { if (context.electronPlatformName !== 'win32') { return } const productName = context.packager?.appInfo?.productFilename || 'Hermes' const exe = path.join(context.appOutDir, `${productName}.exe`) - const desktopRoot = path.resolve(__dirname, '..') + const desktopRoot = path.resolve(import.meta.dirname, '..') try { await stampExeIdentity(exe, desktopRoot) diff --git a/apps/desktop/scripts/assert-dist-built.cjs b/apps/desktop/scripts/assert-dist-built.mjs similarity index 74% rename from apps/desktop/scripts/assert-dist-built.cjs rename to apps/desktop/scripts/assert-dist-built.mjs index 8eea50f45a3..3715eba4e22 100644 --- a/apps/desktop/scripts/assert-dist-built.cjs +++ b/apps/desktop/scripts/assert-dist-built.mjs @@ -1,5 +1,3 @@ -"use strict" - // Build-time guard: refuse to hand a half-built renderer to electron-builder. // // `npm run pack` / `npm run dist*` are `npm run build && npm run builder`. @@ -13,31 +11,32 @@ // inherits it. It fails loud and early instead of shipping a broken bundle. // See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404). -const fs = require("fs") -const path = require("path") +import { existsSync, statSync, readdirSync } from "fs" +import { join, resolve } from "path" +import { isMain } from "./utils.mjs" // Pure check — returns { ok: true } or { ok: false, error: "..." }. // Kept side-effect-free so it can be unit tested without spawning a process. -function checkDistBuilt(distDir) { - if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) { +export function checkDistBuilt(distDir) { + if (!existsSync(distDir) || !statSync(distDir).isDirectory()) { return { ok: false, error: `no dist directory at ${distDir}` } } - const indexHtml = path.join(distDir, "index.html") - if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) { + const indexHtml = join(distDir, "index.html") + if (!existsSync(indexHtml) || !statSync(indexHtml).isFile()) { return { ok: false, error: `dist/index.html is missing at ${indexHtml}` } } - if (fs.statSync(indexHtml).size === 0) { + if (statSync(indexHtml).size === 0) { return { ok: false, error: `dist/index.html is empty at ${indexHtml}` } } // index.html alone isn't enough — vite emits hashed JS into dist/assets. // An index.html with no script bundle still blank-pages. - const assetsDir = path.join(distDir, "assets") + const assetsDir = join(distDir, "assets") const hasAssets = - fs.existsSync(assetsDir) && - fs.statSync(assetsDir).isDirectory() && - fs.readdirSync(assetsDir).some(name => name.endsWith(".js")) + existsSync(assetsDir) && + statSync(assetsDir).isDirectory() && + readdirSync(assetsDir).some(name => name.endsWith(".js")) if (!hasAssets) { return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` } } @@ -46,8 +45,8 @@ function checkDistBuilt(distDir) { } function main() { - const desktopRoot = path.resolve(__dirname, "..") - const distDir = path.join(desktopRoot, "dist") + const desktopRoot = resolve(import.meta.dirname, "..") + const distDir = join(desktopRoot, "dist") const result = checkDistBuilt(distDir) if (!result.ok) { @@ -63,8 +62,8 @@ function main() { console.log("✓ assert-dist-built: dist/index.html + assets present") } -if (require.main === module) { +if (isMain(import.meta.url)) { main() } -module.exports = { checkDistBuilt } +export default { checkDistBuilt } diff --git a/apps/desktop/scripts/assert-dist-built.test.cjs b/apps/desktop/scripts/assert-dist-built.test.mjs similarity index 91% rename from apps/desktop/scripts/assert-dist-built.test.cjs rename to apps/desktop/scripts/assert-dist-built.test.mjs index 5121762469a..7c99ac76d0d 100644 --- a/apps/desktop/scripts/assert-dist-built.test.cjs +++ b/apps/desktop/scripts/assert-dist-built.test.mjs @@ -1,10 +1,10 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test } from 'vitest' -const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs') +import { checkDistBuilt } from '../scripts/assert-dist-built.mjs' function makeDist(extra) { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-')) diff --git a/apps/desktop/scripts/assert-root-install.cjs b/apps/desktop/scripts/assert-root-install.cjs deleted file mode 100644 index 26433ca9be7..00000000000 --- a/apps/desktop/scripts/assert-root-install.cjs +++ /dev/null @@ -1,13 +0,0 @@ -"use strict" - -const fs = require("fs") -const path = require("path") - -const root = path.resolve(__dirname, "..", "..", "..") - -try { - fs.accessSync(path.join(root, "node_modules", "vite", "package.json")) -} catch { - console.error(`Run from repo root: cd ${root} && npm ci`) - process.exit(1) -} diff --git a/apps/desktop/scripts/assert-root-install.mjs b/apps/desktop/scripts/assert-root-install.mjs new file mode 100644 index 00000000000..5dc1d51bdcd --- /dev/null +++ b/apps/desktop/scripts/assert-root-install.mjs @@ -0,0 +1,11 @@ +import { accessSync } from "fs" +import { resolve, join } from "path" + +const root = resolve(import.meta.dirname, "..", "..", "..") + +try { + accessSync(join(root, "node_modules", "vite", "package.json")) +} catch { + console.error(`Run from repo root: cd ${root} && npm ci`) + process.exit(1) +} diff --git a/apps/desktop/scripts/before-build.cjs b/apps/desktop/scripts/before-build.mjs similarity index 89% rename from apps/desktop/scripts/before-build.cjs rename to apps/desktop/scripts/before-build.mjs index 673aca380d3..e9a1d843ae5 100644 --- a/apps/desktop/scripts/before-build.cjs +++ b/apps/desktop/scripts/before-build.mjs @@ -4,8 +4,8 @@ * avoids workspace dependency graph explosions and keeps packaging * deterministic across environments. The Hermes Agent Python payload is no * longer bundled; the Electron app fetches it at first launch via - * `install.ps1`'s stage protocol (Windows). See `electron/main.cjs`. + * `install.ps1`'s stage protocol (Windows). See `electron/main.ts`. */ -module.exports = async function beforeBuild() { +export default async function beforeBuild() { return false } diff --git a/apps/desktop/scripts/before-pack.cjs b/apps/desktop/scripts/before-pack.mjs similarity index 54% rename from apps/desktop/scripts/before-pack.cjs rename to apps/desktop/scripts/before-pack.mjs index 7ef9bcfadc8..8b2359dfba6 100644 --- a/apps/desktop/scripts/before-pack.cjs +++ b/apps/desktop/scripts/before-pack.mjs @@ -1,10 +1,10 @@ -'use strict' - /** - * before-pack.cjs — electron-builder beforePack hook. + * before-pack.mjs — electron-builder beforePack hook. * - * Removes any stale unpacked app directory (`appOutDir`) before - * electron-builder stages the Electron binaries into it. + * Two responsibilities: + * + * 1. Removes any stale unpacked app directory (`appOutDir`) before + * electron-builder stages the Electron binaries into it. * * WHY THIS EXISTS * --------------- @@ -41,30 +41,41 @@ * resolve rather than throw — worst case electron-builder hits the original * ENOENT, which is no worse than not having this hook at all. * + * 2. Re-stages node-pty's native files for the ACTUAL target platform/arch + * of this pack. `npm run build` already staged node-pty once for the + * host machine (see scripts/stage-native-deps.mjs), which is correct for + * single-arch builds matching the host. But electron-builder can target + * a different arch than the host (cross-build), or pack multiple archs + * from one `npm run build` (e.g. `dist:mac` => x64 + arm64). Only this + * hook knows the real per-target arch, via `context.arch` / + * `context.electronPlatformName` — so it re-stages on top of whatever + * `npm run build` left behind, per target, right before files are read + * for packing. + * * electron-builder passes a context with: * - appOutDir: the unpacked app directory about to be staged * - electronPlatformName: 'win32' | 'darwin' | 'linux' + * - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal) */ +import { existsSync, rmSync } from 'node:fs' +import { Arch } from 'electron-builder' +import { stageNodePty } from './stage-native-deps.mjs' -const fs = require('node:fs') - -function cleanStaleAppOutDir(appOutDir) { +export function cleanStaleAppOutDir(appOutDir) { if (!appOutDir || typeof appOutDir !== 'string') { return false } - if (!fs.existsSync(appOutDir)) { + if (!existsSync(appOutDir)) { return false } // Recursive + force so a half-written tree (read-only bits, partial files) // can't block the wipe. retry/maxRetries rides out transient EBUSY on // Windows where an AV/indexer may briefly hold a handle. - fs.rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) return true } -exports.cleanStaleAppOutDir = cleanStaleAppOutDir - -exports.default = async function beforePack(context) { +export default async function beforePack(context) { const appOutDir = context && context.appOutDir try { if (cleanStaleAppOutDir(appOutDir)) { @@ -75,4 +86,26 @@ exports.default = async function beforePack(context) { // directory (permissions, mount) is still diagnosable. console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`) } -} + + try { + const platform = context && context.electronPlatformName + const archName = context && typeof context.arch === 'number' ? Arch[context.arch] : undefined + if (platform && archName) { + if (archName === 'universal') { + console.warn( + '[before-pack] target arch is "universal" — node-pty has no universal prebuild; ' + + 'staged binary will be whichever single-arch copy npm run build left behind. ' + + 'lipo-merge x64/arm64 .node files manually if you need a true universal build.' + ) + } else { + await stageNodePty({ platform, arch: archName }) + console.log(`[before-pack] re-staged node-pty for target ${platform}-${archName}`) + } + } + } catch (err) { + // This one SHOULD fail the build — a missing/wrong native binary for the + // target arch means a broken package shipped to users, which is worse + // than a build that fails loudly here. + throw new Error(`[before-pack] failed to stage node-pty for this target: ${err.message}`) + } +} \ No newline at end of file diff --git a/apps/desktop/scripts/before-pack.test.cjs b/apps/desktop/scripts/before-pack.test.mjs similarity index 86% rename from apps/desktop/scripts/before-pack.test.cjs rename to apps/desktop/scripts/before-pack.test.mjs index 763922aa6f8..44adf961618 100644 --- a/apps/desktop/scripts/before-pack.test.cjs +++ b/apps/desktop/scripts/before-pack.test.mjs @@ -1,10 +1,10 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test } from 'vitest' -const { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs') +import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs' test('cleanStaleAppOutDir removes a populated unpacked directory', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) @@ -45,7 +45,6 @@ test('cleanStaleAppOutDir ignores empty or invalid input', () => { }) test('beforePack default export resolves even when cleanup throws', async () => { - const { default: beforePack } = require('../scripts/before-pack.cjs') // A directory path that rmSync can't remove is simulated by passing a // context whose appOutDir is a file the hook will try (and be allowed) to // remove; the contract under test is that the hook never rejects. diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs new file mode 100644 index 00000000000..316d2ce1b18 --- /dev/null +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// bundle-electron-main.mjs — bundles electron/main.ts and electron/preload.ts +// into self-contained js files in dist/ so the packaged app doesn't need +// node_modules/ or tsx at runtime. +// +// Output: +// dist/electron-main.mjs (MJS bundle — entry point for packaged app) +// dist/electron-preload.js (CJS bundle — loaded via BrowserWindow preload) +// +// `electron` and `node-pty` are external (provided by the runtime / staged +// separately via stage-native-deps). +import { build } from 'esbuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdirSync } from 'node:fs' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, '..') +const distDir = resolve(root, 'dist') +mkdirSync(distDir, { recursive: true }) + +const mainEntry = resolve(root, 'electron/main.ts') +const mainOut = resolve(distDir, 'electron-main.mjs') +const preloadEntry = resolve(root, 'electron/preload.ts') +const preloadOut = resolve(distDir, 'electron-preload.js') + +const external = ['electron', 'node-pty', 'fs'] +// Production bundles bake packaged=true so unpackaged `electron .` still +// behaves like a packaged build. Dev bundles (`--dev`) leave the env alone +// so HERMES_DESKTOP_DEV_SERVER / source-tree resolution keep working. +const isDev = process.argv.includes('--dev') +const define = isDev + ? {} + : { 'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true) } + +// Bundle main.ts → dist/electron-main.mjs +await build({ + entryPoints: [mainEntry], + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + outfile: mainOut, + external, + banner: { + js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);", + }, + define, + logLevel: 'info', +}) +console.log(`bundled ${mainOut}${isDev ? ' (dev)' : ''}`) + +// Bundle preload.ts → dist/electron-preload.js +await build({ + entryPoints: [preloadEntry], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + outfile: preloadOut, + external, + define, + logLevel: 'info', +}) +console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`) diff --git a/apps/desktop/scripts/notarize-artifact.cjs b/apps/desktop/scripts/notarize-artifact.mjs similarity index 83% rename from apps/desktop/scripts/notarize-artifact.cjs rename to apps/desktop/scripts/notarize-artifact.mjs index 89a4901c5cc..e7ea2f024ff 100644 --- a/apps/desktop/scripts/notarize-artifact.cjs +++ b/apps/desktop/scripts/notarize-artifact.mjs @@ -1,7 +1,7 @@ -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { execFile } = require('node:child_process') +import { existsSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFile } from 'node:child_process' function run(command, args) { return new Promise((resolve, reject) => { @@ -26,7 +26,7 @@ function resolveApiKeyPath(rawValue) { const value = String(rawValue || '').trim() if (!value) return { keyPath: '', cleanup: () => {} } - if (fs.existsSync(value)) { + if (existsSync(value)) { return { keyPath: value, cleanup: () => {} } } @@ -34,17 +34,17 @@ function resolveApiKeyPath(rawValue) { throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content') } - const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`) - fs.writeFileSync(tempPath, value, 'utf8') + const tempPath = join(tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`) + writeFileSync(tempPath, value, 'utf8') return { keyPath: tempPath, - cleanup: () => fs.rmSync(tempPath, { force: true }) + cleanup: () => rmSync(tempPath, { force: true }) } } async function main() { const artifactPath = process.argv[2] - if (!artifactPath || !fs.existsSync(artifactPath)) { + if (!artifactPath || !existsSync(artifactPath)) { throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`) } diff --git a/apps/desktop/scripts/notarize.cjs b/apps/desktop/scripts/notarize.mjs similarity index 93% rename from apps/desktop/scripts/notarize.cjs rename to apps/desktop/scripts/notarize.mjs index 1508e18e803..49294469e55 100644 --- a/apps/desktop/scripts/notarize.cjs +++ b/apps/desktop/scripts/notarize.mjs @@ -1,7 +1,7 @@ -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { execFile } = require('node:child_process') +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFile } from 'node:child_process' function run(command, args) { return new Promise((resolve, reject) => { @@ -49,7 +49,7 @@ function resolveApiKeyPath(rawValue) { } } -exports.default = async function notarize(context) { +export default async function notarize(context) { const { electronPlatformName, appOutDir, packager } = context if (electronPlatformName !== 'darwin') return diff --git a/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs b/apps/desktop/scripts/patch-electron-builder-mac-binary.mjs similarity index 96% rename from apps/desktop/scripts/patch-electron-builder-mac-binary.cjs rename to apps/desktop/scripts/patch-electron-builder-mac-binary.mjs index b88c281219f..6cfa41f32ec 100644 --- a/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs +++ b/apps/desktop/scripts/patch-electron-builder-mac-binary.mjs @@ -1,11 +1,11 @@ -const fs = require('node:fs') -const path = require('node:path') +import fs from 'node:fs' +import path from 'node:path' if (process.platform !== 'darwin') { process.exit(0) } -const desktopRoot = path.resolve(__dirname, '..') +const desktopRoot = path.resolve(import.meta.dirname, '..') const repoRoot = path.resolve(desktopRoot, '..', '..') const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js') diff --git a/apps/desktop/scripts/rebuild-native.mjs b/apps/desktop/scripts/rebuild-native.mjs new file mode 100644 index 00000000000..ddec5ea318e --- /dev/null +++ b/apps/desktop/scripts/rebuild-native.mjs @@ -0,0 +1,22 @@ +// rebuild-native.mjs +import { rebuild } from '@electron/rebuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { isMain } from './utils.mjs' +import packageJson from '../package.json' with { type: 'json' } +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +export async function rebuildNodePty({ arch = process.arch } = {}) { + await rebuild({ + buildPath: projectRoot, // where node_modules lives + electronVersion: packageJson.devDependencies.electron.replace('^', ''), + arch, + onlyModules: ['node-pty'], + force: true + }) +} + +if (isMain(import.meta.url)) { + const [arch] = process.argv.slice(2) + await rebuildNodePty({ arch }) +} diff --git a/apps/desktop/scripts/run-electron-builder.cjs b/apps/desktop/scripts/run-electron-builder.mjs similarity index 89% rename from apps/desktop/scripts/run-electron-builder.cjs rename to apps/desktop/scripts/run-electron-builder.mjs index 100d6c346e9..38e465612f9 100644 --- a/apps/desktop/scripts/run-electron-builder.cjs +++ b/apps/desktop/scripts/run-electron-builder.mjs @@ -1,14 +1,15 @@ -"use strict" - // Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can // re-unpack a broken Electron.app; reusing the installed dist dodges that. // npm workspace hoisting is non-deterministic — require.resolve finds electron // wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let // electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR). -const fs = require("node:fs") -const path = require("node:path") -const { spawnSync } = require("node:child_process") +import fs from "node:fs" +import path from "node:path" +import { spawnSync } from "node:child_process" +import { createRequire } from "node:module" + +const require = createRequire(import.meta.url) function electronDistDir() { try { diff --git a/apps/desktop/scripts/set-exe-identity.cjs b/apps/desktop/scripts/set-exe-identity.mjs similarity index 70% rename from apps/desktop/scripts/set-exe-identity.cjs rename to apps/desktop/scripts/set-exe-identity.mjs index 129e1505bda..4e19999c754 100644 --- a/apps/desktop/scripts/set-exe-identity.cjs +++ b/apps/desktop/scripts/set-exe-identity.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// set-exe-identity.cjs — stamp the Hermes icon + version metadata onto the +// set-exe-identity.mjs — stamp the Hermes icon + version metadata onto the // built Hermes.exe using rcedit, completely decoupled from electron-builder's // signing path. // @@ -20,7 +20,7 @@ // // HOW IT RUNS // ----------- -// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.cjs), +// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.mjs), // so EVERY packed build — first install, `hermes desktop`, the installer's // --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from // one place. Previously this stamp lived only in install.ps1, so the update @@ -28,40 +28,34 @@ // shipped a stock "Electron" exe. Keeping it in afterPack closes that gap. // // Also runnable standalone for ad-hoc re-stamping: -// node scripts/set-exe-identity.cjs <path-to-Hermes.exe> +// node scripts/set-exe-identity.mjs <path-to-Hermes.exe> // // Exits 0 on success, non-zero on failure when run as a CLI. As a hook, // stampExeIdentity() resolves on success and rejects on failure; the caller -// (after-pack.cjs) swallows the rejection so a stamp failure never fails an +// (after-pack.mjs) swallows the rejection so a stamp failure never fails an // otherwise-good build (worst case: stock icon, not a broken app). -const path = require('node:path') -const fs = require('node:fs') +import { resolve, join } from 'node:path' +import { existsSync } from 'node:fs' + +import { rcedit } from 'rcedit' + +import { isMain } from './utils.mjs' // Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on // failure. `desktopRoot` defaults to this script's package root so the icon and // the rcedit dependency resolve regardless of cwd. -async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')) { - if (!exe || !fs.existsSync(exe)) { +async function stampExeIdentity(exe, desktopRoot = resolve(import.meta.dirname, '..')) { + if (!exe || !existsSync(exe)) { throw new Error(`target exe not found: ${exe}`) } // Icon lives at apps/desktop/assets/icon.ico - const icon = path.join(desktopRoot, 'assets', 'icon.ico') - if (!fs.existsSync(icon)) { + const icon = join(desktopRoot, 'assets', 'icon.ico') + if (!existsSync(icon)) { throw new Error(`icon not found: ${icon}`) } - // rcedit is a direct devDependency of apps/desktop, so it resolves whether - // we're run from the desktop dir or the repo root (workspace hoist). - // rcedit@5 exports a NAMED `rcedit` function (CommonJS: { rcedit }), not a - // default export. - const mod = require('rcedit') - const rcedit = typeof mod === 'function' ? mod : mod.rcedit - if (typeof rcedit !== 'function') { - throw new Error(`unexpected rcedit export shape: ${typeof mod} keys=${Object.keys(mod)}`) - } - console.log(`[set-exe-identity] stamping ${exe}`) console.log(`[set-exe-identity] icon: ${icon}`) @@ -78,13 +72,13 @@ async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..') console.log('[set-exe-identity] done — Hermes icon + identity stamped') } -module.exports = { stampExeIdentity } +export { stampExeIdentity } -// CLI entry point: `node scripts/set-exe-identity.cjs <exe>`. -if (require.main === module) { +// CLI entry point: `node scripts/set-exe-identity.mjs <exe>`. +if (isMain(import.meta.url)) { const exe = process.argv[2] if (!exe) { - console.error('[set-exe-identity] usage: set-exe-identity.cjs <path-to-exe>') + console.error('[set-exe-identity] usage: set-exe-identity.mjs <path-to-exe>') process.exit(2) } stampExeIdentity(exe).catch(err => { diff --git a/apps/desktop/scripts/stage-native-deps.cjs b/apps/desktop/scripts/stage-native-deps.cjs deleted file mode 100644 index ef68368dee7..00000000000 --- a/apps/desktop/scripts/stage-native-deps.cjs +++ /dev/null @@ -1,283 +0,0 @@ -'use strict' - -/** - * Stage native node-modules dependencies for electron-builder packaging. - * - * Workspace dedup hoists `node-pty` into the root `node_modules/`, which - * electron-builder's default file collector (when `files:` is explicitly set - * in package.json) cannot reach. The result: packaged builds ship with no - * .node binaries and PTY initialization fails at runtime ("PTY support is - * unavailable"). - * - * Rather than restructure the workspace dedup (would require nohoist / - * package.json shenanigans and risk breaking dev) or balloon the package - * with the whole node_modules tree, we copy ONLY the runtime-essential - * files of the native dep into apps/desktop/build/native-deps/ and ship - * THAT subtree via extraResources. main.cjs falls back to require()-ing - * from process.resourcesPath when the hoisted-root require fails. - * - * Runs as part of `npm run build`. Idempotent -- always re-stages on each - * build to pick up native binary updates. - * - * Layout note: upstream node-pty (microsoft/node-pty 1.x) is N-API based - * and ships its prebuilts under `prebuilds/<platform>-<arch>/` instead of - * `build/Release/`. Its runtime resolver (lib/utils.js) checks - * build/Release first and falls through to the per-arch prebuilds dir, so - * shipping only the latter is sufficient for packaged runs. Per-arch - * staging keeps the resource bundle lean -- we only need the target - * arch's prebuilt, not all of them. - */ - -const fs = require('node:fs') -const path = require('node:path') - -const APP_ROOT = path.resolve(__dirname, '..') -const REPO_ROOT = path.resolve(APP_ROOT, '..', '..') -const STAGE_ROOT = path.join(APP_ROOT, 'build', 'native-deps') - -// The target arch may be overridden by electron-builder via npm_config_arch -// (e.g. `npm run dist -- --arm64`); fall back to the build host's arch. -const TARGET_ARCH = process.env.npm_config_arch || process.arch -const TARGET_PLATFORM = process.platform - -// Modules to stage. The "from" path is the hoisted location in the workspace -// root; "to" is the layout we want inside build/native-deps/. The "include" -// globs (relative to "from") select the runtime-essential files. Anything -// outside the include list is left behind (source, deps/, scripts/, etc.). -const NATIVE_DEPS = [ - { - from: path.join(REPO_ROOT, 'node_modules', 'node-pty'), - to: path.join(STAGE_ROOT, 'node-pty'), - include: [ - 'package.json', - 'lib/*.js', - 'lib/**/*.js', - 'build/Release/*.node', - // Per-arch runtime payload. Explicit file types so we don't ship the - // ~25 MB of .pdb debug symbols that prebuild-install bundles for - // Windows crash analysis -- not used at runtime, would just bloat - // the installer. - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.node`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.dll`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.exe`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/spawn-helper`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/conpty/*` - ] - } -] - -// Pure-JS runtime dependencies that the packaged electron main require()s but -// that workspace dedup hoists into the repo-root node_modules -- out of reach -// of electron-builder's file collector, exactly like node-pty above. Unlike -// node-pty there is no native binary to select; we stage each package's whole -// directory into build/native-deps/vendor/node_modules/<name> so the dep's own -// internal require()s resolve against a real node_modules tree, and the -// requiring file (electron/git-review-ops.cjs) falls back to that path via -// process.resourcesPath when the normal require() fails. See issue #52735 -// (packaged app crashed at launch on `Cannot find module 'simple-git'`). -// -// The closure is resolved at stage time by walking dependencies + -// optionalDependencies, so a simple-git version bump that pulls in a new -// transitive dep can't silently re-introduce the crash. -// -// Layout note: the closure lands in build/native-deps/vendor/node_modules/, -// NOT build/native-deps/node_modules/. electron-builder's file collector -// hard-drops a `node_modules` directory that sits at the ROOT of an -// extraResources copy (app-builder-lib/out/util/filter.js: `if (relative === -// "node_modules") return false`), but keeps a NESTED one. Nesting under -// `vendor/` makes node_modules a subdirectory so it survives packing; the -// require() fallback in git-review-ops.cjs resolves the matching -// vendor/node_modules path. -const JS_DEP_ROOTS = ['simple-git'] -const JS_DEP_STAGE_ROOT = path.join(STAGE_ROOT, 'vendor', 'node_modules') - -function rmrf(target) { - fs.rmSync(target, { recursive: true, force: true }) -} - -function ensureDir(target) { - fs.mkdirSync(target, { recursive: true }) -} - -function walk(root) { - const results = [] - const stack = [root] - while (stack.length) { - const current = stack.pop() - let entries - try { - entries = fs.readdirSync(current, { withFileTypes: true }) - } catch { - continue - } - for (const entry of entries) { - const full = path.join(current, entry.name) - if (entry.isDirectory()) { - stack.push(full) - } else if (entry.isFile()) { - results.push(full) - } - } - } - return results -} - -// Match a relative path against simple ** and * glob patterns. Implementation -// is intentionally tiny -- the include lists are small and don't need full -// minimatch support. -function matchGlob(rel, pattern) { - const r = rel.replace(/\\/g, '/') - const re = new RegExp( - '^' + - pattern - .replace(/\\/g, '/') - .replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\*\*/g, '__DOUBLE_STAR__') - .replace(/\*/g, '[^/]*') - .replace(/__DOUBLE_STAR__/g, '.*') + - '$' - ) - return re.test(r) -} - -function stageOne(spec) { - if (!fs.existsSync(spec.from)) { - throw new Error( - `stage-native-deps: source missing at ${spec.from}. Run \`npm install\` ` + - `at the workspace root first.` - ) - } - rmrf(spec.to) - ensureDir(spec.to) - - const files = walk(spec.from) - let copied = 0 - for (const abs of files) { - const rel = path.relative(spec.from, abs) - const included = spec.include.some(g => matchGlob(rel, g)) - if (!included) continue - const dest = path.join(spec.to, rel) - ensureDir(path.dirname(dest)) - fs.copyFileSync(abs, dest) - // node-pty's darwin spawn-helper and the Windows helper binaries - // (OpenConsole.exe, winpty-agent.exe) are invoked via posix_spawn / - // CreateProcess at runtime, so they must remain executable in the - // staged tree. fs.copyFileSync preserves source mode on POSIX, but we - // re-assert +x defensively for the darwin spawn-helper (no extension - // means a stripped mode would be silently broken at runtime). - if (path.basename(rel) === 'spawn-helper' && process.platform !== 'win32') { - try { fs.chmodSync(dest, 0o755) } catch { /* best-effort */ } - } - copied += 1 - } - console.log(`[stage-native-deps] ${path.relative(APP_ROOT, spec.to)}: ${copied} files`) -} - -// Resolve a package's directory by name, searching the repo-root node_modules -// first (where workspace dedup hoists everything) and then the requiring -// package's own node_modules for any non-hoisted nested copy. -// -// We deliberately do NOT use require.resolve(`${name}/package.json`): packages -// with an "exports" map that doesn't list "./package.json" (e.g. simple-git -// 3.x) make that subpath unresolvable under Node's exports enforcement -// (ERR_PACKAGE_PATH_NOT_EXPORTED), which fails on CI even though it happened to -// work locally. Instead resolve the package's main entry (exports-aware) and -// walk up to the directory whose package.json's "name" matches. -function resolvePkgDir(name, fromDir) { - const searchPaths = [fromDir, REPO_ROOT, path.join(REPO_ROOT, 'node_modules')] - let entry - try { - entry = require.resolve(name, { paths: searchPaths }) - } catch { - return null - } - // Walk up from the resolved entry file to the package root: the first - // ancestor dir whose package.json declares this package's name. - let dir = path.dirname(entry) - while (true) { - const pjPath = path.join(dir, 'package.json') - try { - const pj = JSON.parse(fs.readFileSync(pjPath, 'utf8')) - if (pj.name === name) { - return dir - } - } catch { - // no package.json here (or unreadable) — keep walking up - } - const parent = path.dirname(dir) - if (parent === dir) { - return null - } - dir = parent - } -} - -// Walk dependencies + optionalDependencies from each root package and return -// the set of resolved package directories in the runtime closure. Keyed by -// package name so a dep reached via two paths is staged once. -function resolveJsClosure(roots) { - const closure = new Map() // name -> absolute package dir - const stack = roots.map(name => ({ name, fromDir: REPO_ROOT })) - while (stack.length) { - const { name, fromDir } = stack.pop() - if (closure.has(name)) continue - const dir = resolvePkgDir(name, fromDir) - if (!dir) { - throw new Error( - `stage-native-deps: could not resolve '${name}' for the simple-git ` + - `closure. Run \`npm install\` at the workspace root first.` - ) - } - closure.set(name, dir) - let pj - try { - pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) - } catch { - continue - } - const deps = { ...(pj.dependencies || {}), ...(pj.optionalDependencies || {}) } - for (const depName of Object.keys(deps)) { - stack.push({ name: depName, fromDir: dir }) - } - } - return closure -} - -// Stage the resolved JS dependency closure into build/native-deps/vendor/node_modules/ -// so the packaged app (and the nix output) can require() it from -// process.resourcesPath when the hoisted-root require() isn't reachable. Each -// package is copied whole (minus node_modules/ — the closure is flattened so -// every dep already has its own top-level entry) into a real node_modules -// layout, which keeps the deps' own internal require()s working unchanged. -function stageJsClosure(roots) { - const closure = resolveJsClosure(roots) - rmrf(JS_DEP_STAGE_ROOT) - ensureDir(JS_DEP_STAGE_ROOT) - let staged = 0 - for (const [name, fromDir] of closure) { - const dest = path.join(JS_DEP_STAGE_ROOT, name) - ensureDir(path.dirname(dest)) - // Copy the package directory but skip any nested node_modules/ — the - // closure is flattened, so nested copies would just bloat the bundle. - fs.cpSync(fromDir, dest, { - recursive: true, - filter: src => path.basename(src) !== 'node_modules' - }) - staged += 1 - } - console.log( - `[stage-native-deps] vendor/node_modules/: ${staged} package(s) ` + - `(${[...closure.keys()].sort().join(', ')})` - ) -} - -function main() { - rmrf(STAGE_ROOT) - ensureDir(STAGE_ROOT) - for (const spec of NATIVE_DEPS) { - stageOne(spec) - } - stageJsClosure(JS_DEP_ROOTS) -} - -main() diff --git a/apps/desktop/scripts/stage-native-deps.mjs b/apps/desktop/scripts/stage-native-deps.mjs new file mode 100644 index 00000000000..8966fece6cb --- /dev/null +++ b/apps/desktop/scripts/stage-native-deps.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node +// stage-native-deps.mjs — stages node-pty's native runtime dependencies +// +// Usage: +// node scripts/stage-native-deps.mjs # host platform/arch +// node scripts/stage-native-deps.mjs win32 arm64 # explicit target +// +// Also exported as `stageNodePty({ platform, arch })` for use from +// before-pack.mjs, where electron-builder gives you the real per-target +// platform/arch during multi-arch builds. + +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { dirname, resolve, join } from 'node:path' +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync +} from 'node:fs' +import { spawnSync } from 'node:child_process' +import { isMain } from './utils.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const projectRoot = resolve(here, '..') +const require = createRequire(import.meta.url) + +/** + * Locate node-pty's package root via real module resolution, so this + * works whether it's hoisted to a workspace root or local to this app. + */ +function resolveNodePtyRoot() { + const pkgJsonPath = require.resolve('node-pty/package.json', { + paths: [projectRoot] + }) + return dirname(pkgJsonPath) +} + +function copyGlobByExt(srcDir, destDir, extensions) { + if (!existsSync(srcDir)) return + mkdirSync(destDir, { recursive: true }) + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + copyGlobByExt(join(srcDir, entry.name), join(destDir, entry.name), extensions) + continue + } + if (extensions.some((ext) => entry.name.endsWith(ext))) { + mkdirSync(destDir, { recursive: true }) + cpSync(join(srcDir, entry.name), join(destDir, entry.name)) + } + } +} + +/** + * Copies the locally-compiled build/Release output (used when no prebuild + * was available and node-pty was built from source for the host machine). + * + * Filters by name/pattern rather than extension only: macOS builds a + * separate `spawn-helper` executable (no file extension) that + * lib/unixTerminal.js requires at a fixed relative path. Filtering this + * directory by ['.node'] silently drops it — the package then looks + * fine, ships fine, and crashes the first time a terminal is spawned. + * Directories are copied wholesale to also cover any nested native + * payload (e.g. a conpty/ subfolder some build layouts produce). + */ +function copyBuildRelease(srcDir, destDir) { + if (!existsSync(srcDir)) return + mkdirSync(destDir, { recursive: true }) + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + cpSync(join(srcDir, entry.name), join(destDir, entry.name), { recursive: true }) + continue + } + if (entry.name === 'spawn-helper' || /\.(node|dll|exe)$/.test(entry.name)) { + cpSync(join(srcDir, entry.name), join(destDir, entry.name)) + } + } +} + +// ─── binary classification ─────────────────────────────────────────── +// +// .node files are shared libraries in the target platform's native binary +// format. By reading the first few bytes (magic) we can determine which +// platform a given .node was compiled for, without shelling out to `file`. +// +// ELF (\x7fELF) → linux +// Mach-O 32-bit BE (feedface) → darwin +// Mach-O 64-bit BE (feedfacf) → darwin +// Mach-O 32-bit LE (cefaedfe — CIGAM) → darwin +// Mach-O 64-bit LE (cffaedfe — CIGAM_64) → darwin +// Fat/Universal BE (cafebabe) → darwin +// Fat/Universal LE (bebafeca — FAT_CIGAM) → darwin +// PE (MZ DOS header) → win32 +// +// Mach-O and Fat binaries are stored on disk in the host's native byte +// order. On x64/arm64 Darwin (every Apple Silicon + every Intel Mac that +// ships node-pty prebuilds) that is little-endian, so the on-disk magic is +// the CIGAM byte-swapped form, NOT the big-endian MH_MAGIC form. Checking +// only the BE constants misclassifies every real Darwin prebuild as unknown. +// +// Exported for unit testing. + +/** + * Classify a native binary's target platform from its magic bytes. + * Returns `'linux'`, `'darwin'`, `'win32'`, or `null` if unrecognized + * or the file cannot be read. + */ +export function classifyNativeBinary(filePath) { + let buf + try { + buf = readFileSync(filePath, { start: 0, end: 63 }) // first 64 bytes + } catch { + return null + } + if (buf.length < 4) return null + + // ELF: \x7f E L F + if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) { + return 'linux' + } + // Mach-O 32-bit (big-endian / MH_MAGIC): feedface + if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xce) { + return 'darwin' + } + // Mach-O 64-bit (big-endian / MH_MAGIC_64): feedfacf + if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xcf) { + return 'darwin' + } + // Mach-O 32-bit (little-endian / MH_CIGAM): cefaedfe + if (buf[0] === 0xce && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) { + return 'darwin' + } + // Mach-O 64-bit (little-endian / MH_CIGAM_64): cffaedfe + if (buf[0] === 0xcf && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) { + return 'darwin' + } + // Fat/Universal binary (big-endian / FAT_MAGIC): cafebabe + if (buf[0] === 0xca && buf[1] === 0xfe && buf[2] === 0xba && buf[3] === 0xbe) { + return 'darwin' + } + // Fat/Universal binary (little-endian / FAT_CIGAM): bebafeca + if (buf[0] === 0xbe && buf[1] === 0xba && buf[2] === 0xfe && buf[3] === 0xca) { + return 'darwin' + } + // PE: MZ DOS header + if (buf[0] === 0x4d && buf[1] === 0x5a) { + return 'win32' + } + return null +} + +/** + * Scan the staged destination tree for .node files and verify each one's + * binary platform matches the requested target. Throws on any mismatch. + * + * This is the fail-closed safety net: even if a prebuild or build/Release + * somehow slipped through with the wrong platform, this catches it before + * the package ships a broken native binary to users. + */ +function validateStagedBinaries(destRoot, targetPlatform) { + const mismatches = [] + function scan(dir, relPrefix) { + if (!existsSync(dir)) return + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + scan(join(dir, entry.name), `${relPrefix}${entry.name}/`) + continue + } + if (!entry.name.endsWith('.node')) continue + const fullPath = join(dir, entry.name) + const classified = classifyNativeBinary(fullPath) + if (classified !== targetPlatform) { + mismatches.push({ file: `${relPrefix}${entry.name}`, classified, expected: targetPlatform }) + } + } + } + scan(join(destRoot, 'prebuilds'), 'prebuilds/') + scan(join(destRoot, 'build', 'Release'), 'build/Release/') + if (mismatches.length > 0) { + throw new Error( + `[stage-native-deps] native binary platform mismatch (target=${targetPlatform}):\n` + + mismatches + .map((m) => ` ${m.file}: expected ${m.expected}, got ${m.classified ?? 'unknown'}`) + .join('\n') + + `\nRefusing to stage a binary compiled for the wrong platform.` + ) + } +} + +/** + * Stage node-pty's native runtime dependencies into `destRoot`. + * + * Exported separately from `stageNodePty` so tests can supply a fake + * node-pty source tree without going through real module resolution. + * + * Strategy (fail-closed): + * + * 1. Copy the matching prebuild (`prebuilds/<platform>-<arch>/`) if present. + * 2. Copy `build/Release/` **only when the target matches the host** — + * build/Release contains a binary compiled for the host's platform/arch, + * so staging it for a different target ships a broken app. + * 3. If no native binary was staged: + * - Same platform as host, different arch → run `electron-rebuild --arch`. + * - Different platform from host → throw (cannot cross-compile native + * modules; build on the target platform or provide a prebuild). + * 4. Validate every staged `.node` file's binary platform matches the target. + */ +export function stageNodePtyInto(srcRoot, destRoot, { platform = process.platform, arch = process.arch } = {}) { + const hostMatch = platform === process.platform && arch === process.arch + + rmSync(destRoot, { recursive: true, force: true }) + mkdirSync(destRoot, { recursive: true }) + + // package.json — needed so `require('node-pty')` resolves the package + // (reads "main") rather than treating it as a directory with no entry. + cpSync(join(srcRoot, 'package.json'), join(destRoot, 'package.json')) + + // lib/**/*.js — the JS surface node-pty's `main` points into. + copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js']) + + // prebuilds/<platform>-<arch>/* — the prebuild-install payload for the + // *target* we're packaging, not necessarily the host running this script. + // Explicit extensions only, to skip the ~25MB of Windows .pdb symbols + // prebuild-install bundles alongside the .node/.dll. + const prebuildDir = join(srcRoot, 'prebuilds', `${platform}-${arch}`) + if (existsSync(prebuildDir)) { + const destPrebuild = join(destRoot, 'prebuilds', `${platform}-${arch}`) + mkdirSync(destPrebuild, { recursive: true }) + for (const entry of readdirSync(prebuildDir, { withFileTypes: true })) { + if (entry.name === 'conpty' && entry.isDirectory()) { + cpSync(join(prebuildDir, 'conpty'), join(destPrebuild, 'conpty'), { recursive: true }) + continue + } + if (entry.isFile() && /\.(node|dll|exe)$/.test(entry.name)) { + cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name)) + continue + } + if (entry.name === 'spawn-helper') { + cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name)) + chmodSync(join(destPrebuild, entry.name), 0o775) + } + } + } + + // build/Release/* — present when node-pty was compiled locally + // (e.g. no prebuild available for this Electron ABI/platform combo). + // Only stage this when the target matches the host, because + // build/Release contains a binary compiled for the *host's* platform + // and architecture. Staging a host binary for a different target (e.g. + // a macOS Mach-O .node staged for a linux-arm64 target) ships a broken + // app that crashes the first time a terminal is spawned. + if (hostMatch) { + const buildReleaseDir = join(srcRoot, 'build/Release') + copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release')) + } + + // Check whether a native binary for this target was staged. + const stagedDirs = [ + join(destRoot, 'prebuilds', `${platform}-${arch}`), + join(destRoot, 'build/Release') + ] + const hasNativeBinary = stagedDirs.some((dir) => { + if (!existsSync(dir)) return false + return readdirSync(dir, { recursive: true }).some((name) => String(name).endsWith('.node')) + }) + + if (!hasNativeBinary) { + if (platform !== process.platform) { + throw new Error( + `[stage-native-deps] no prebuilt binary for ${platform}-${arch} and ` + + `cannot cross-compile native modules from ${process.platform}-${process.arch}. ` + + `Build on the target platform or provide a prebuild.` + ) + } + // Same platform, possibly different arch — rebuild from source with + // the target architecture so electron-rebuild produces the correct + // binary rather than defaulting to the host's arch. + console.log( + `[stage-native-deps] no native binary for ${platform}-${arch}; ` + + `running electron-rebuild (target arch: ${arch})...` + ) + const rebuildArgs = [ + '../../node_modules/.bin/electron-rebuild', + '-f', + '-w', + 'node-pty', + '--arch', + arch + ] + const result = spawnSync(process.execPath, rebuildArgs, { + cwd: projectRoot, + stdio: 'inherit' + }) + if (result.status !== 0) { + throw new Error( + `electron-rebuild failed for ${platform}-${arch} (exit ${result.status}). ` + + `Cannot stage node-pty without a native binary.` + ) + } + // Re-copy build/Release after electron-rebuild populated it. + const buildReleaseDir = join(srcRoot, 'build/Release') + copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release')) + } + + // Validate every staged .node binary matches the target platform. + validateStagedBinaries(destRoot, platform) + + console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`) + return destRoot +} + +export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) { + const srcRoot = resolveNodePtyRoot() + const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty') + return stageNodePtyInto(srcRoot, destRoot, { platform, arch }) +} + +// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch] +if (isMain(import.meta.url)) { + const [platform, arch] = process.argv.slice(2) + stageNodePty({ platform, arch }) +} diff --git a/apps/desktop/scripts/stage-native-deps.test.mjs b/apps/desktop/scripts/stage-native-deps.test.mjs new file mode 100644 index 00000000000..65e1bb5d302 --- /dev/null +++ b/apps/desktop/scripts/stage-native-deps.test.mjs @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict' +import fs, { existsSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test } from 'vitest' + +import { + stageNodePtyInto, + classifyNativeBinary +} from '../scripts/stage-native-deps.mjs' + +const { join } = path + +// ─── fixtures ────────────────────────────────────────────────────── +// +// Create minimal fake .node files with correct magic bytes so the +// binary classifier and the staging validator exercise real code paths +// without needing actual native modules. + +/** Write a fake .node file with the given platform's magic bytes. */ +function makeFakeNode(filePath, platform) { + const headers = { + linux: Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]), // ELF + // On x64/arm64 Darwin, Mach-O binaries are stored little-endian on disk + // (MH_CIGAM_64 = cffaedfe). This is the form node-pty's prebuilds ship in. + darwin: Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00, 0x00, 0x00]), // Mach-O 64-bit LE (CIGAM_64) + win32: Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // MZ (PE) + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, headers[platform] ?? headers.linux) +} + +/** Create a minimal fake node-pty source tree in a temp dir. */ +function makeFakeNodePty(srcRoot, { prebuildPlatform, prebuildArch } = {}) { + fs.mkdirSync(srcRoot, { recursive: true }) + fs.writeFileSync(join(srcRoot, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'lib/index.js' })) + fs.mkdirSync(join(srcRoot, 'lib'), { recursive: true }) + fs.writeFileSync(join(srcRoot, 'lib', 'index.js'), 'module.exports = {};') + + if (prebuildPlatform && prebuildArch) { + const prebuildDir = join(srcRoot, 'prebuilds', `${prebuildPlatform}-${prebuildArch}`) + makeFakeNode(join(prebuildDir, 'pty.node'), prebuildPlatform) + } +} + +// ─── classifyNativeBinary tests ───────────────────────────────────── + +test('classifyNativeBinary detects ELF as linux', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'linux') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Mach-O 64-bit BE as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xcf, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Mach-O 64-bit LE (CIGAM_64) as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Mach-O 32-bit BE as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xce, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Mach-O 32-bit LE (CIGAM) as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xce, 0xfa, 0xed, 0xfe, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Fat/Universal BE (cafebabe) as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Fat/Universal LE (bebafeca / FAT_CIGAM) as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xbe, 0xba, 0xfe, 0xca, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects PE (MZ) as win32', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'win32') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary returns null for unrecognized magic', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), null) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary returns null for a missing file', () => { + assert.equal(classifyNativeBinary('/nonexistent/path/to/thing.node'), null) +}) + +// ─── cross-target regression tests ────────────────────────────────── +// +// The core bug: stageNodePty receives { platform, arch } from +// electron-builder but unconditionally copies host build/Release, staging +// a host binary for a foreign target. These tests prove the fix: +// +// 1. A host build/Release must NOT be staged for a foreign platform. +// 2. A matching prebuild IS staged for a foreign target. +// 3. A foreign target with no prebuild throws (fail closed). +// 4. A host build/Release IS staged for a matching target. +// 5. Validation rejects a binary whose magic bytes don't match the target. + +test('cross-target: host build/Release is NOT staged for a foreign platform', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'node-pty') + const destRoot = join(tmp, 'dest') + + // Create a node-pty tree with ONLY a host build/Release (no prebuild). + makeFakeNodePty(srcRoot) + const buildReleaseDir = join(srcRoot, 'build', 'Release') + makeFakeNode(join(buildReleaseDir, 'pty.node'), process.platform) + + // Request a foreign platform (different from the host). + const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux' + + assert.throws( + () => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }), + /cannot cross-compile/i + ) + + // build/Release must NOT have been copied to the dest tree. + assert.equal( + existsSync(join(destRoot, 'build', 'Release', 'pty.node')), + false, + 'host build/Release .node must not be staged for a foreign target' + ) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('cross-target: matching prebuild IS staged for a foreign target', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'node-pty') + const destRoot = join(tmp, 'dest') + + // Host is (say) darwin. Request linux-x64, which has a prebuild. + const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux' + makeFakeNodePty(srcRoot, { prebuildPlatform: foreignPlatform, prebuildArch: 'x64' }) + + // Also create a host build/Release that should NOT be staged. + makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform) + + stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }) + + // The foreign prebuild must be staged. + const stagedPrebuild = join(destRoot, 'prebuilds', `${foreignPlatform}-x64`, 'pty.node') + assert.equal(existsSync(stagedPrebuild), true, 'foreign prebuild must be staged') + + // The host build/Release must NOT be staged. + assert.equal( + existsSync(join(destRoot, 'build', 'Release', 'pty.node')), + false, + 'host build/Release must not be staged for a foreign target' + ) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('cross-target: foreign target with no prebuild throws (fail closed)', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'node-pty') + const destRoot = join(tmp, 'dest') + + // Create a tree with a host build/Release but no foreign prebuild. + makeFakeNodePty(srcRoot) + makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform) + + const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux' + + assert.throws( + () => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }), + /cannot cross-compile/i + ) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('host-target: host build/Release IS staged for a matching target', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'node-pty') + const destRoot = join(tmp, 'dest') + + makeFakeNodePty(srcRoot) + makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform) + + stageNodePtyInto(srcRoot, destRoot, { platform: process.platform, arch: process.arch }) + + assert.equal( + existsSync(join(destRoot, 'build', 'Release', 'pty.node')), + true, + 'host build/Release must be staged for a matching target' + ) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('validation rejects a staged binary with the wrong platform magic', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'node-pty') + const destRoot = join(tmp, 'dest') + + // Create a prebuild dir that claims to be linux-x64 but contains + // a darwin (Mach-O) binary. This simulates the original bug where + // a host binary ends up in a foreign target's prebuild slot. + makeFakeNodePty(srcRoot, { prebuildPlatform: 'linux', prebuildArch: 'x64' }) + // Overwrite the prebuild .node with the WRONG platform magic. + makeFakeNode(join(srcRoot, 'prebuilds', 'linux-x64', 'pty.node'), 'darwin') + + assert.throws( + () => stageNodePtyInto(srcRoot, destRoot, { platform: 'linux', arch: 'x64' }), + /platform mismatch/i + ) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/scripts/test-desktop.mjs b/apps/desktop/scripts/test-desktop.mjs index fdff1523f8f..ec94ddc5e7f 100644 --- a/apps/desktop/scripts/test-desktop.mjs +++ b/apps/desktop/scripts/test-desktop.mjs @@ -5,10 +5,11 @@ import { spawn, spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { listPackage } from '@electron/asar' -const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8')) +import PACKAGE_JSON from '../package.json' with { type: 'json' } + const MODE = process.argv[2] || 'help' const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64' +const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release') const PLATFORM = process.platform @@ -41,14 +42,14 @@ const APP = (() => { const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked') return { appPath: unpacked, - binary: path.join(unpacked, 'hermes'), + binary: path.join(unpacked, 'Hermes'), resourcesPath: path.join(unpacked, 'resources'), asarPath: path.join(unpacked, 'resources', 'app.asar'), unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html') } })() -// Default HERMES_HOME for non-sandboxed runs -- matches main.cjs's +// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's // resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere // it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own // HERMES_HOME and never touches this. @@ -83,17 +84,23 @@ function exists(target) { return fs.existsSync(target) } -// Match nodepty native binding location to what main.cjs's resolver fallback -// expects (apps/desktop/electron/main.cjs, packaged-build branch). Upstream -// node-pty 1.x is N-API based and ships per-arch prebuilts under -// prebuilds/<platform>-<arch>/ instead of build/Release/. We check the -// per-arch dir since that's what stage-native-deps actually copies. +// Match node-pty native binding location to what the bundled electron-main.cjs +// resolves at runtime. stage-native-deps.mjs stages node-pty into +// dist/node_modules/node-pty, and dist/** is asarUnpacked (see package.json +// build.asarUnpack), so in a packaged build it lands under +// resources/app.asar.unpacked/dist/node_modules/node-pty — reachable by a bare +// require('node-pty') from the bundle. Upstream node-pty 1.x is N-API based and +// ships per-arch prebuilts under prebuilds/<platform>-<arch>/; nix/local builds +// instead compile from source into build/Release/. The stage script copies +// whichever is present, so we accept either as the native payload. function expectedNativeDepPaths() { - const root = path.join(APP.resourcesPath, 'native-deps', 'node-pty') + const root = path.join(APP.resourcesPath, 'app.asar.unpacked', 'dist', 'node_modules', 'node-pty') const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`) + const buildReleaseDir = path.join(root, 'build', 'Release') return { packageJson: path.join(root, 'package.json'), prebuildsDir, + buildReleaseDir, libIndex: path.join(root, 'lib', 'index.js') } } @@ -101,10 +108,9 @@ function expectedNativeDepPaths() { function ensurePlatformBuilds() { if (PLATFORM === 'darwin') return if (PLATFORM === 'win32') return + if (PLATFORM === 'linux') return die( - `Desktop bundle validation is only wired for darwin / win32 today; platform=${PLATFORM} ` + - `is not yet supported. The thin-installer story for Linux ships in Phase 2 alongside ` + - `install.sh's stage protocol.` + `Desktop bundle validation is only wired for darwin / win32 / linux; platform=${PLATFORM} is not supported.` ) } @@ -279,8 +285,8 @@ function launchFresh() { // - The Hermes Agent Python payload is NOT shipped (it's fetched at first // launch via install.ps1's stage protocol). // - install-stamp.json IS shipped in resources/ with a valid commit + branch. -// - native-deps/@homebridge/node-pty-prebuilt-multiarch/ IS shipped with -// the package.json + lib/ + at least one .node binary (the renderer's +// - node-pty IS shipped inside app.asar.unpacked/dist/node_modules/node-pty +// with package.json + lib/ + at least one .node binary (the renderer's // integrated terminal needs this; see Phase 1F.6). // - The renderer's dist/index.html is reachable (either unpacked or // inside app.asar). @@ -320,24 +326,35 @@ function validateBundle() { // Positive assertion: node-pty native deps shipped const native = expectedNativeDepPaths() if (!exists(native.packageJson)) { - die(`Missing node-pty package.json in resources/native-deps: ${native.packageJson}`) + die(`Missing node-pty package.json in app.asar.unpacked: ${native.packageJson}`) } if (!exists(native.libIndex)) { - die(`Missing node-pty lib/index.js in resources/native-deps: ${native.libIndex}`) + die(`Missing node-pty lib/index.js in app.asar.unpacked: ${native.libIndex}`) } - if (!exists(native.prebuildsDir)) { - die(`Missing node-pty prebuilds dir for ${PLATFORM}-${ARCH}: ${native.prebuildsDir}`) + // The native binary lands in prebuilds/<platform>-<arch>/ (downloaded prebuild) + // OR build/Release/ (compiled from source). stage-native-deps.mjs copies + // whichever is present, so accept either. + const nativeBinaryDirs = [native.prebuildsDir, native.buildReleaseDir].filter(exists) + if (nativeBinaryDirs.length === 0) { + die( + `Missing node-pty native binary dir for ${PLATFORM}-${ARCH}: neither ` + + `${native.prebuildsDir} nor ${native.buildReleaseDir} exists` + ) } - const nodeBinaries = fs.readdirSync(native.prebuildsDir).filter(name => name.endsWith('.node')) + const nodeBinaries = nativeBinaryDirs.flatMap(dir => + fs.readdirSync(dir).filter(name => name.endsWith('.node')) + ) if (nodeBinaries.length === 0) { - die(`No .node native binaries found in: ${native.prebuildsDir}`) + die(`No .node native binaries found in: ${nativeBinaryDirs.join(', ')}`) } // Darwin requires a runtime-execed spawn-helper alongside pty.node; missing // it manifests as "ENOENT: spawn-helper" on first pty.spawn() call. if (PLATFORM === 'darwin') { - const spawnHelper = path.join(native.prebuildsDir, 'spawn-helper') - if (!exists(spawnHelper)) { - die(`Missing node-pty spawn-helper (required on darwin): ${spawnHelper}`) + const spawnHelper = nativeBinaryDirs + .map(dir => path.join(dir, 'spawn-helper')) + .find(exists) + if (!spawnHelper) { + die(`Missing node-pty spawn-helper (required on darwin) in: ${nativeBinaryDirs.join(', ')}`) } } diff --git a/apps/desktop/scripts/utils.mjs b/apps/desktop/scripts/utils.mjs new file mode 100644 index 00000000000..7010213ec3c --- /dev/null +++ b/apps/desktop/scripts/utils.mjs @@ -0,0 +1,8 @@ + +import { pathToFileURL } from 'node:url'; + +// returns true if the passsed file is being invoked from node, +// not imported. +export function isMain(importMetaUrl) { + return importMetaUrl === pathToFileURL(process.argv[1]).href; +} \ No newline at end of file diff --git a/apps/desktop/scripts/write-build-stamp.cjs b/apps/desktop/scripts/write-build-stamp.mjs similarity index 86% rename from apps/desktop/scripts/write-build-stamp.cjs rename to apps/desktop/scripts/write-build-stamp.mjs index 72b978c5f9a..005db35d177 100644 --- a/apps/desktop/scripts/write-build-stamp.cjs +++ b/apps/desktop/scripts/write-build-stamp.mjs @@ -1,10 +1,8 @@ -"use strict" - /** * Writes apps/desktop/build/install-stamp.json with the git ref the desktop * .exe should pin to at first-launch bootstrap time. This file ships inside * the packaged app via electron-builder's extraResources entry and is read - * by electron/main.cjs to drive the install.ps1 stage bootstrap flow. + * by electron/main.ts to drive the install.ps1 stage bootstrap flow. * * Schema (subject to bump via STAMP_SCHEMA_VERSION): * { @@ -26,16 +24,16 @@ * bootstrap without a stamp. */ -const fs = require("fs") -const path = require("path") -const { execSync } = require("child_process") +import { mkdirSync, writeFileSync } from "fs" +import { resolve, join, relative } from "path" +import { execSync } from "child_process" const STAMP_SCHEMA_VERSION = 1 -const DESKTOP_ROOT = path.resolve(__dirname, "..") -const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..") -const OUT_DIR = path.join(DESKTOP_ROOT, "build") -const OUT_FILE = path.join(OUT_DIR, "install-stamp.json") +const DESKTOP_ROOT = resolve(import.meta.dirname, "..") +const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..") +const OUT_DIR = join(DESKTOP_ROOT, "build") +const OUT_FILE = join(OUT_DIR, "install-stamp.json") function tryExec(cmd, opts) { try { @@ -111,11 +109,11 @@ function main() { source: stamp.source } - fs.mkdirSync(OUT_DIR, { recursive: true }) - fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8") + mkdirSync(OUT_DIR, { recursive: true }) + writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8") console.log( "[write-build-stamp] wrote " + - path.relative(REPO_ROOT, OUT_FILE) + + relative(REPO_ROOT, OUT_FILE) + " -> " + stamp.commit.slice(0, 12) + (stamp.branch ? " (" + stamp.branch + ")" : "") + diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 5ae0cd42474..70a6a0338ca 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -216,7 +216,10 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . const titles = [...new Set(artifacts.map(artifact => artifact.sessionTitle).filter(Boolean))].slice(0, 2) - const hints = [...extensions.map(ext => t.common.tryHint(`.${ext}`)), ...titles.map(title => t.common.tryHint(title))] + const hints = [ + ...extensions.map(ext => t.common.tryHint(`.${ext}`)), + ...titles.map(title => t.common.tryHint(title)) + ] return hints.length > 0 ? hints : undefined }, [artifacts, t]) diff --git a/apps/desktop/src/app/chat/composer/attachments.test.tsx b/apps/desktop/src/app/chat/composer/attachments.test.tsx index 0ea85811315..52e8f6bf98e 100644 --- a/apps/desktop/src/app/chat/composer/attachments.test.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { I18nProvider } from '@/i18n/context' @@ -10,12 +10,17 @@ function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment { return { id, kind: 'file', label } } -function renderWithI18n(ui: React.ReactNode) { - return render( - <I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}> - {ui} - </I18nProvider> - ) +async function renderWithI18n(ui: React.ReactNode) { + let result: ReturnType<typeof render> + await act(async () => { + result = render( + <I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}> + {ui} + </I18nProvider> + ) + }) + + return result! } describe('AttachmentList', () => { @@ -23,23 +28,22 @@ describe('AttachmentList', () => { cleanup() }) - it('renders valid attachments', () => { + it('renders valid attachments', async () => { const attachments = [makeAttachment('a', 'doc.pdf'), makeAttachment('b', 'img.png')] - renderWithI18n(<AttachmentList attachments={attachments} />) + await renderWithI18n(<AttachmentList attachments={attachments} />) expect(screen.getByText('doc.pdf')).toBeDefined() expect(screen.getByText('img.png')).toBeDefined() }) - it('renders empty list without error', () => { - renderWithI18n(<AttachmentList attachments={[]} />) + it('renders empty list without error', async () => { + const { container } = await renderWithI18n(<AttachmentList attachments={[]} />) - const container = - screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]') + const attachmentList = container.querySelector('[data-slot="composer-attachments"]') - expect(container).toBeDefined() + expect(attachmentList).toBeDefined() }) - it('does not crash when attachments array contains undefined entries', () => { + it('does not crash when attachments array contains undefined entries', async () => { // Repro: session switch can leave stale/undefined entries in the // attachments array, causing a TypeError at attachment.refText. const attachments = [ @@ -48,21 +52,17 @@ describe('AttachmentList', () => { makeAttachment('b', 'also-good.png') ] - expect(() => { - renderWithI18n(<AttachmentList attachments={attachments} />) - }).not.toThrow() + await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy() // Only valid attachments should render expect(screen.getByText('good.pdf')).toBeDefined() expect(screen.getByText('also-good.png')).toBeDefined() }) - it('does not crash when attachments array contains null entries', () => { + it('does not crash when attachments array contains null entries', async () => { const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')] - expect(() => { - renderWithI18n(<AttachmentList attachments={attachments} />) - }).not.toThrow() + await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy() expect(screen.getByText('valid.txt')).toBeDefined() }) diff --git a/apps/desktop/src/app/chat/composer/composer-utils.test.ts b/apps/desktop/src/app/chat/composer/composer-utils.test.ts index 9fc5f5b5730..4df8463ba2d 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.test.ts @@ -1,7 +1,14 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' import { describe, expect, it } from 'vitest' -import { pickPlaceholder, slashArgStage, slashChipKindForItem, slashCommandToken } from './composer-utils' +import { + isPendingDraftPersistCurrent, + type PendingDraftPersist, + pickPlaceholder, + slashArgStage, + slashChipKindForItem, + slashCommandToken +} from './composer-utils' const item = (group: string): Unstable_TriggerItem => ({ id: 'x', type: 'slash', label: 'x', metadata: { group } }) as unknown as Unstable_TriggerItem @@ -38,3 +45,36 @@ describe('pickPlaceholder', () => { expect(pool).toContain(pickPlaceholder(pool)) }) }) + +describe('isPendingDraftPersistCurrent (#54527 integrity guard)', () => { + it('accepts a write when the pending entry still matches what was captured', () => { + const entry: PendingDraftPersist = { scope: 'session-a', text: 'hello' } + + expect(isPendingDraftPersistCurrent(entry, entry)).toBe(true) + expect(isPendingDraftPersistCurrent({ scope: 'session-a', text: 'hello' }, entry)).toBe(true) + }) + + it('rejects when the pending slot was cleared (session swap / newer flush already committed)', () => { + const entry: PendingDraftPersist = { scope: 'session-a', text: 'hello' } + + expect(isPendingDraftPersistCurrent(null, entry)).toBe(false) + }) + + it('rejects when the pending slot now belongs to a different session (the #54527 misroute shape)', () => { + const captured: PendingDraftPersist = { scope: 'session-a', text: 'carefully composed prompt' } + const supersededBy: PendingDraftPersist = { scope: 'session-b', text: 'different draft' } + + expect(isPendingDraftPersistCurrent(supersededBy, captured)).toBe(false) + }) + + it('rejects when the pending slot was replaced by a newer keystroke in the same session', () => { + const captured: PendingDraftPersist = { scope: 'session-a', text: 'first draft' } + const supersededBy: PendingDraftPersist = { scope: 'session-a', text: 'first draft continued' } + + expect(isPendingDraftPersistCurrent(supersededBy, captured)).toBe(false) + }) + + it('rejects when nothing was ever captured', () => { + expect(isPendingDraftPersistCurrent(null, null)).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index ad7b63787fd..66f438f6270 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -58,3 +58,28 @@ export interface QueueEditState { } export const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) + +export interface PendingDraftPersist { + scope: string | null + text: string +} + +/** + * Defense-in-depth for #54527: the debounce timer and the `pagehide` flush + * both write a captured `{ scope, text }` pair some time after it was + * scheduled. Before either commits the write, this checks the pair is still + * the one currently on file — i.e. nothing cleared or replaced it in the + * meantime (a session swap, a newer keystroke). The scope-capture fix + * upstream (`draftScopeRef`) already makes every captured pair correct by + * construction; this guard exists so that if a future change reintroduces a + * stale/live-ref read at one of these call sites, the write is dropped + * instead of silently filing one session's text under another session's key. + */ +export function isPendingDraftPersistCurrent( + pending: PendingDraftPersist | null, + expected: PendingDraftPersist | null +): boolean { + return ( + pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text + ) +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 5f8bcf8e233..52cb605f541 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -5,7 +5,12 @@ import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer' import { isBrowsingHistory } from '@/store/composer-input-history' -import { cloneAttachments, DRAFT_PERSIST_DEBOUNCE_MS, type QueueEditState } from '../composer-utils' +import { + cloneAttachments, + DRAFT_PERSIST_DEBOUNCE_MS, + isPendingDraftPersistCurrent, + type QueueEditState +} from '../composer-utils' import { type ComposerInsertMode, focusComposerInput, @@ -77,6 +82,13 @@ export function useComposerDraft({ const draftPersistTimerRef = useRef<number | undefined>(undefined) const activeQueueSessionKeyRef = useRef(activeQueueSessionKey) activeQueueSessionKeyRef.current = activeQueueSessionKey + // Owned only by the swap effect below — unlike activeQueueSessionKeyRef this + // does NOT update on every render, so it always reflects the session whose + // text is actually loaded in the editor. Async work (debounce timers, + // pagehide flush) must persist against this, not the render-time ref, or a + // session switch mid-flight files one session's draft under another's key + // (#54527). + const draftScopeRef = useRef(activeQueueSessionKey) const sessionIdRef = useRef(sessionId) sessionIdRef.current = sessionId const queueEditStateRef = useRef<QueueEditState | null>(queueEditRef.current) @@ -222,10 +234,20 @@ export function useComposerDraft({ return } - const scope = activeQueueSessionKeyRef.current - pendingDraftPersistRef.current = { scope, text } + const scope = draftScopeRef.current + const entry = { scope, text } + pendingDraftPersistRef.current = entry window.clearTimeout(draftPersistTimerRef.current) draftPersistTimerRef.current = window.setTimeout(() => { + // Integrity guard (defense-in-depth, #54527): only commit if this is + // still the pending write on file. A session swap or a newer + // keystroke clears/replaces it before firing in the normal case; this + // catches any future call site that skips that bookkeeping instead of + // silently filing text under the wrong session. + if (!isPendingDraftPersistCurrent(pendingDraftPersistRef.current, entry)) { + return + } + pendingDraftPersistRef.current = null stashAt(scope, text) }, DRAFT_PERSIST_DEBOUNCE_MS) @@ -285,6 +307,14 @@ export function useComposerDraft({ // never clears composer state; this effect alone stashes on leave, restores // on enter. Keyed writes are idempotent, so no skip-sentinel. useEffect(() => { + // A pending debounce timer from the outgoing session is now stale — its + // scope was correct when scheduled, but the authoritative stash below + // (and the cleanup on the way out) already covers that text. Letting it + // fire later would just clobber with an older snapshot. + window.clearTimeout(draftPersistTimerRef.current) + pendingDraftPersistRef.current = null + draftScopeRef.current = activeQueueSessionKey + const { attachments, text } = takeSessionDraft(activeQueueSessionKey) loadIntoComposer(text, attachments) @@ -304,7 +334,7 @@ export function useComposerDraft({ // inside the debounce/rAF window would drop trailing keystrokes without this. useEffect(() => { const flushPendingDraftPersist = () => { - const scope = activeQueueSessionKeyRef.current + const scope = draftScopeRef.current const editing = queueEditStateRef.current if (editing?.sessionKey === scope || isBrowsingHistory(sessionIdRef.current)) { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index eab822d7cd8..2dc0ef8047f 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -79,7 +79,11 @@ export function useComposerSubmit({ const restore = () => { loadIntoComposer(text, submittedAttachments) - stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments) + // Use the scope captured at dispatch, not whatever session is focused + // now — the gateway can reject well after the user has switched away, + // and re-stashing into the currently-focused session would overwrite + // its draft with the rejected text from a different session (#54527). + stashAt(submittedScope, text, submittedAttachments) } void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx new file mode 100644 index 00000000000..3bc38ed92b0 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx @@ -0,0 +1,30 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { PreviewStatusRow } from './preview-row' + +describe('PreviewStatusRow', () => { + afterEach(() => { + cleanup() + }) + + it('keeps the preview tooltip label inline inside the portaled decoration', async () => { + const view = render( + <PreviewStatusRow + item={{ cwd: 'C:\\repo', id: 'preview.html', label: 'preview.html', target: 'preview.html' }} + onDismiss={() => undefined} + /> + ) + + fireEvent.pointerMove(screen.getByText('preview.html'), { pointerType: 'mouse' }) + await screen.findByRole('tooltip') + + const content = document.querySelector<HTMLElement>('[data-slot="tooltip-content"]') + const label = content?.firstElementChild?.firstElementChild + + expect(content).not.toBeNull() + expect(view.container.contains(content)).toBe(false) + expect(label?.classList.contains('inline-flex')).toBe(true) + expect(label?.classList.contains('flex')).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx index 5e559365112..cf721d2ae93 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -113,7 +113,9 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss > <Tip label={ - <span className="flex flex-col gap-0.5"> + // inline-flex (not flex): a block child collapses Tip's decoration + // wrapper geometry and mis-positions the tooltip (#62022). + <span className="inline-flex flex-col gap-0.5"> <span>{item.target}</span> <span className="opacity-70">{t.preview.linkHint}</span> </span> diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts index f80e6db4385..6c6a20780f6 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -46,6 +46,14 @@ describe('detectTrigger', () => { expect(detectTrigger('/path/to/file')).toBeNull() }) + it('does not trigger slash popover mid-message', () => { + expect(detectTrigger('hello /')).toBeNull() + expect(detectTrigger('hello /skill')).toBeNull() + expect(detectTrigger('hello there /personality alic')).toBeNull() + expect(detectTrigger('text\n/skill')).toBeNull() + expect(detectTrigger('multi word message /')).toBeNull() + }) + it('still anchors at-mention triggers strictly at the token edge', () => { expect(detectTrigger('@file:path with space')).toBeNull() }) diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index 4535d6963c3..b9b6adc07f1 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -11,8 +11,12 @@ export interface TriggerState { // user types args (`/personality alic` → arg completer suggests `alice`). // Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file // paths like `src/foo/bar`. +// +// Slash commands only execute at the beginning of a message, so the `/` +// trigger is anchored strictly at position 0 — not after whitespace — to +// avoid opening the popover mid-message (e.g. `hello /`). const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/ -const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ +const SLASH_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ /** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */ export function blobDedupeKey(blob: Blob): string { diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 74eb8df8661..fb8175102b0 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -12,6 +12,7 @@ import { useLocation } from 'react-router-dom' import { Thread } from '@/components/assistant-ui/thread' import { Backdrop } from '@/components/Backdrop' +import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts' import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -19,11 +20,19 @@ import { ErrorState } from '@/components/ui/error-state' import { getGlobalModelOptions, type HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' -import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' +import { + coalesceToolOnlyAssistants, + createToolMergeCache, + quickModelOptions, + sessionTitle, + toRuntimeMessage +} from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { $pinnedSessionIds } from '@/store/layout' +import { $petActive } from '@/store/pet' +import { $petOverlayActive } from '@/store/pet-overlay' import { $gatewaySwapTarget } from '@/store/profile' import { $activeSessionId, @@ -201,6 +210,7 @@ function ChatRuntimeBoundary({ const storeMessages = useStore($messages) const messages = suppressMessages ? NO_MESSAGES : storeMessages const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>()) + const toolMergeCacheRef = useRef(createToolMergeCache()) const runtimeMessageRepository = useMemo(() => { const items: { message: ThreadMessage; parentId: string | null }[] = [] @@ -208,7 +218,7 @@ function ChatRuntimeBoundary({ let visibleParentId: string | null = null let headId: string | null = null - for (const message of messages) { + for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) { let parentId = visibleParentId if (message.role === 'assistant' && message.branchGroupId) { @@ -290,6 +300,10 @@ export function ChatView({ const currentCwd = useStore($currentCwd) const currentModel = useStore($currentModel) const currentProvider = useStore($currentProvider) + // A pet anywhere (in-window or popped out) owns the hearts; composer only when none. + const petActive = useStore($petActive) + const petOverlayActive = useStore($petOverlayActive) + const petPresent = petActive || petOverlayActive const freshDraftReady = useStore($freshDraftReady) const gatewayState = useStore($gatewayState) const gatewaySwapTarget = useStore($gatewaySwapTarget) @@ -484,6 +498,18 @@ export function ChatView({ </div> )} {showChatBar && <ScrollToBottomButton />} + {/* Vibe hearts rise from the composer only when no pet is out (else + they play on the pet). Fired by the core `reaction` event. */} + {!petPresent && ( + <HeartField + className="absolute inset-x-0 z-30" + config={COMPOSER_HEART_CONFIG} + style={{ + top: 0, + bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.25rem)' + }} + /> + )} <ChatDropOverlay kind={dragKind} /> <ChatSwapOverlay profile={gatewaySwapTarget} /> </div> diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx index 51e5539bac9..650900a42e9 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx @@ -19,7 +19,7 @@ describe('PreviewPane console state', () => { vi.unstubAllGlobals() }) - it('does not watch backend-only remote filesystem previews locally', () => { + it('does not watch backend-only remote filesystem previews locally', async () => { const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' })) const onPreviewFileChanged = vi.fn(() => vi.fn()) $connection.set({ mode: 'remote' } as never) @@ -31,38 +31,43 @@ describe('PreviewPane console state', () => { } }) - render( - <PreviewPane - setTitlebarToolGroup={vi.fn()} - target={{ - kind: 'file', - label: 'file.txt', - path: '/remote/file.txt', - previewKind: 'text', - source: '/remote/file.txt', - url: 'file:///remote/file.txt' - }} - /> - ) + await act(async () => { + render( + <PreviewPane + setTitlebarToolGroup={vi.fn()} + target={{ + kind: 'file', + label: 'file.txt', + path: '/remote/file.txt', + previewKind: 'text', + source: '/remote/file.txt', + url: 'file:///remote/file.txt' + }} + /> + ) + }) expect(watchPreviewFile).not.toHaveBeenCalled() expect(onPreviewFileChanged).not.toHaveBeenCalled() }) - it('does not rebuild the pane titlebar group for streamed console logs', () => { + it('does not rebuild the pane titlebar group for streamed console logs', async () => { const setTitlebarToolGroup = vi.fn() - const rendered = render( - <PreviewPane - setTitlebarToolGroup={setTitlebarToolGroup} - target={{ - kind: 'url', - label: 'Preview', - source: 'http://localhost:5174', - url: 'http://localhost:5174' - }} - /> - ) + let rendered!: ReturnType<typeof render> + await act(async () => { + rendered = render( + <PreviewPane + setTitlebarToolGroup={setTitlebarToolGroup} + target={{ + kind: 'url', + label: 'Preview', + source: 'http://localhost:5174', + url: 'http://localhost:5174' + }} + /> + ) + }) const initialCalls = setTitlebarToolGroup.mock.calls.length const webview = rendered.container.querySelector('webview') diff --git a/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx b/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx new file mode 100644 index 00000000000..c3870be0135 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx @@ -0,0 +1,160 @@ +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import type { HermesGitBaseBranch } from '@/global' +import { useI18n } from '@/i18n' +import { $repoStatus } from '@/store/coding-status' +import { listBaseBranches } from '@/store/projects' + +// Filterable combobox for picking the base branch of a new worktree. Lists +// local + remote-tracking branches, defaults to the default branch +// (origin/HEAD, or local main/master when no remote). The current session's +// branch is sorted to the top so it's one click away. The parent owns the +// selected value via `value` / `onValueChange`. +export function BaseBranchPicker({ + disabled, + repoPath, + onValueChange, + value +}: { + disabled?: boolean + repoPath: string + onValueChange: (value: string) => void + value: string +}) { + const { t } = useI18n() + const p = t.sidebar.projects + const repoStatus = useStore($repoStatus) + const [branches, setBranches] = useState<HermesGitBaseBranch[]>([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + + const currentBranch = repoStatus?.detached ? null : (repoStatus?.branch ?? null) + + const load = useCallback(async () => { + if (!repoPath) { + return + } + + setLoading(true) + + try { + const list = await listBaseBranches(repoPath) + setBranches(list) + + // Default to the remote default (origin/HEAD). Fall back to the local + // default branch (main/master) when no remote exists. The value is + // always a concrete branch — never undefined. + const defaultBranch = list.find(b => b.isDefault) + + if (defaultBranch) { + onValueChange(defaultBranch.name) + } else { + onValueChange(list[0]?.name ?? '') + } + } catch { + setBranches([]) + } finally { + setLoading(false) + } + }, [repoPath, onValueChange]) + + // Load on mount so the default branch fills in before the user opens the + // popover — otherwise the button reads "branch off " with nothing after it. + useEffect(() => { + if (branches.length === 0 && !loading) { + void load() + } + }, [branches.length, loading, load]) + + // Pin the current session's branch to the top, keep the rest in git's + // most-recently-committed order. + const sorted = useMemo(() => { + if (!currentBranch) { + return branches + } + + const idx = branches.findIndex(b => b.name === currentBranch) + + if (idx <= 0) { + return branches + } + + return [branches[idx], ...branches.slice(0, idx), ...branches.slice(idx + 1)] + }, [branches, currentBranch]) + + // The i18n function returns { before, after } so the branch name can be + // wrapped in its own styled (underlined) span — works for any word order. + const parts = p.branchOff() + + return ( + <div className="space-y-1.5"> + <Popover + onOpenChange={next => { + if (next && branches.length === 0 && !loading) { + void load() + } + + setOpen(next) + }} + open={open} + > + <PopoverTrigger asChild> + <Button + className="group w-full flex justify-start items-center min-w-0 gap-1.5 hover:no-underline hover:text-muted-foreground" + disabled={disabled || loading} + size="inline" + variant="text" + > + <Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" /> + <span className="shrink-0">{parts.before}</span> + <span className="shrink-0 text-primary underline-offset-4 decoration-current/20 group-hover:underline"> + {loading ? '...' : value} + </span> + <Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.75rem" /> + <span className="shrink-0">{parts.after}</span> + </Button> + </PopoverTrigger> + <PopoverContent align="start" className="z-[140] min-w-(--radix-popover-trigger-width) p-0"> + <Command filter={(searchValue, search) => (searchValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}> + <CommandInput autoFocus placeholder={p.baseBranchPlaceholder} /> + <CommandList className="max-h-64"> + <CommandEmpty>{p.baseBranchNone}</CommandEmpty> + <CommandGroup> + {sorted.map(branch => ( + <CommandItem + key={branch.name} + onSelect={() => { + onValueChange(branch.name) + setOpen(false) + }} + value={branch.name} + > + <div className="flex items-center justify-start gap-1.5"> + <Codicon + className="shrink-0 text-(--ui-text-tertiary)" + name={branch.isRemote ? 'repo' : 'git-branch'} + size="0.8rem" + /> + {branch.isDefault && ( + <span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">★</span> + )} + <span className="truncate">{branch.name}</span> + {value === branch.name && ( + <Codicon className="ml-auto shrink-0 text-(--ui-accent)" name="check" size="0.8rem" /> + )} + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + ) +} diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts index fcd18086abc..f4ca8424d2f 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts @@ -494,6 +494,29 @@ describe('liveSessionProjectId', () => { expect(id).toBe('p_app') }) + + it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => { + // The bug: a fresh Windows session drops into the overlay before the next + // backend refresh; case-sensitive matching missed its project until then. + const id = liveSessionProjectId(makeSession('c:/work/notes/SUB'), [makeProject('p_notes', ['C:\\Work\\Notes'])]) + + expect(id).toBe('p_notes') + }) + + it('matches a root-relative WSL cwd (single backslash) case-insensitively', () => { + const id = liveSessionProjectId(makeSession('//wsl.localhost/Ubuntu/home/alice/PROJ'), [ + makeProject('p_proj', ['\\wsl.localhost\\Ubuntu\\home\\alice\\proj']) + ]) + + expect(id).toBe('p_proj') + }) + + it('keeps POSIX cwd matching case-sensitive (no false project match)', () => { + // Distinct case on POSIX is a distinct path → falls back to its own auto id. + expect(liveSessionProjectId(makeSession('/work/notes'), [makeProject('p_notes', ['/Work/Notes'])])).toBe( + '/work/notes' + ) + }) }) describe('overlayLiveLanes', () => { diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index 899a59e6979..04e18f7c7b9 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -73,6 +73,27 @@ const segments = (path: string): string[] => /** A path with trailing separators stripped, for stable equality checks. */ const normalizePath = (path: null | string | undefined): string => (path ?? '').replace(/[/\\]+$/, '') +// Windows spellings: drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any +// backslash-rooted path (`\wsl.localhost\…`). A single leading `/` stays POSIX. +// Mirrors the backend `_is_windows_path` so the live overlay places rows into +// the same project the backend tree would. +const isWindowsPath = (path: string): boolean => + /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\') || path.startsWith('//') + +/** + * Segments for identity comparison: Windows paths fold case (and separators, via + * {@link segments}) so `C:\Work` and `c:/work` are one lane; POSIX stays + * case-sensitive. Comparison-only — emitted ids/labels keep their spelling. + */ +const comparisonSegments = (path: string): string[] => { + const segs = segments(path) + + return isWindowsPath(path) ? segs.map(seg => seg.toLowerCase()) : segs +} + +/** Canonical per-host comparison key (separator/case/trailing-slash agnostic). */ +const pathKey = (path: null | string | undefined): string => comparisonSegments(path ?? '').join('/') + /** Last path segment. */ export const baseName = (path: string): string | undefined => segments(path).pop() @@ -317,8 +338,8 @@ export function mergeRepoWorktreeGroups( /** True when `target` equals `folder` or is nested under it (segment-wise). */ function isPathUnder(folder: string, target: string): boolean { - const f = segments(folder) - const t = segments(target) + const f = comparisonSegments(folder) + const t = comparisonSegments(target) if (!f.length || f.length > t.length) { return false @@ -347,9 +368,8 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro // No persisted repo root yet (brand-new session) → the cwd is the root. const repoRoot = (session.git_repo_root || '').trim() || cwd - const underRepo = cwd === repoRoot || cwd.startsWith(`${repoRoot}/`) || cwd.startsWith(`${repoRoot}\\`) - if (!underRepo) { + if (!isPathUnder(repoRoot, cwd)) { return null } @@ -423,7 +443,7 @@ export function overlayRepoLanes( live: SessionInfo[], removed: ReadonlySet<string> = NO_REMOVED ): SidebarWorkspaceTree { - const repoRoot = normalizePath(repo.path) + const repoRootKey = pathKey(repo.path) let changed = false // Snapshot lanes minus anything the user just deleted/archived. @@ -457,7 +477,7 @@ export function overlayRepoLanes( for (const g of lanes) { const lanePath = normalizePath(g.path) - if (!lanePath || lanePath === repoRoot || !isPathUnder(lanePath, cwd)) { + if (!lanePath || pathKey(lanePath) === repoRootKey || !isPathUnder(lanePath, cwd)) { continue } @@ -480,14 +500,14 @@ export function overlayRepoLanes( continue } - const placedPath = normalizePath(placed.path) + const placedKey = pathKey(placed.path) lane = lanes.find(g => g.id === placed.id) ?? (placed.isMain ? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase()) : undefined) ?? - (!placed.isMain && placedPath ? lanes.find(g => normalizePath(g.path) === placedPath) : undefined) + (!placed.isMain && placedKey ? lanes.find(g => pathKey(g.path) === placedKey) : undefined) if (!lane) { lane = { ...placed, sessions: [] } diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx index 1a32f68b2f5..e184ac871a0 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx @@ -30,6 +30,8 @@ import { copyPath, listRepoBranches, revealPath, startWorkInRepo, switchBranchIn import { SidebarCount, SidebarRowLead } from '../chrome' +import { BaseBranchPicker } from './base-branch-picker' + // Branch/worktree labels routinely share a long prefix (`bb/coding-context-…`), // so plain end-truncation (`truncate`) hides exactly the suffix that tells two // lanes apart — both render as "bb/coding-context…". Keep the tail pinned and @@ -142,6 +144,8 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov // "New worktree": prompt for a branch name, then git spins up a fresh worktree // for that branch under the repo (the lightest way) and we open a new session // inside it. Naming is explicit — no auto-generated `hermes/work-<ts>` trees. +// The base branch defaults to the remote default (origin/HEAD); the user can +// pick any local or remote-tracking branch via a filterable combobox. export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onStarted: (path: string) => void }) { const { t } = useI18n() const s = t.sidebar @@ -152,6 +156,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS const [convertMode, setConvertMode] = useState(false) const [branches, setBranches] = useState<HermesGitBranch[]>([]) const [branchesLoading, setBranchesLoading] = useState(false) + const [selectedBase, setSelectedBase] = useState('') const loadBranches = useCallback(async () => { if (!repoPath) { @@ -181,7 +186,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS try { // Pass the typed value as both the dir slug source and the branch, so the // branch is exactly what the user named (the dir is slugified git-side). - const result = await startWorkInRepo(repoPath, { branch, name: branch }) + const result = await startWorkInRepo(repoPath, { base: selectedBase || undefined, branch, name: branch }) if (result) { onStarted(result.path) @@ -238,6 +243,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS onClick={() => { setConvertMode(false) setName('') + setSelectedBase('') setOpen(true) }} type="button" @@ -278,22 +284,30 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS </CommandList> </Command> ) : ( - <SanitizedInput - autoFocus - disabled={pending} - onKeyDown={event => { - if (event.key === 'Enter') { - event.preventDefault() - void submit() - } else if (event.key === 'Escape') { - setOpen(false) - } - }} - onValueChange={setName} - placeholder={p.branchPlaceholder} - sanitize={gitRef} - value={name} - /> + <> + <SanitizedInput + autoFocus + disabled={pending} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + void submit() + } else if (event.key === 'Escape') { + setOpen(false) + } + }} + onValueChange={setName} + placeholder={p.branchPlaceholder} + sanitize={gitRef} + value={name} + /> + <BaseBranchPicker + disabled={pending} + onValueChange={setSelectedBase} + repoPath={repoPath} + value={selectedBase} + /> + </> )} {convertMode ? ( diff --git a/apps/desktop/src/app/cron/cron-job-model.test.ts b/apps/desktop/src/app/cron/cron-job-model.test.ts new file mode 100644 index 00000000000..14873e29987 --- /dev/null +++ b/apps/desktop/src/app/cron/cron-job-model.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model' + +describe('jobIsScriptOnly', () => { + it('is true when no_agent is set and a script is present', () => { + expect(jobIsScriptOnly({ no_agent: true, script: 'echo hi' })).toBe(true) + }) + + it('is false for agent-backed jobs', () => { + expect(jobIsScriptOnly({ no_agent: false, script: 'echo hi' })).toBe(false) + expect(jobIsScriptOnly({ no_agent: true, script: '' })).toBe(false) + expect(jobIsScriptOnly({ no_agent: true, script: null })).toBe(false) + }) +}) + +describe('validateCronEditor', () => { + it('requires prompt and schedule for agent-backed jobs', () => { + expect(validateCronEditor({ prompt: '', schedule: '', scriptOnlyJob: false })).toBe('prompt_and_schedule') + expect(validateCronEditor({ prompt: '', schedule: '0 9 * * *', scriptOnlyJob: false })).toBe('prompt') + expect(validateCronEditor({ prompt: 'go', schedule: '', scriptOnlyJob: false })).toBe('schedule') + }) + + it('allows an empty prompt when editing a script-only job', () => { + expect(validateCronEditor({ prompt: '', schedule: '0 9 * * 1', scriptOnlyJob: true })).toBe(null) + expect(validateCronEditor({ prompt: 'optional note', schedule: '0 9 * * 1', scriptOnlyJob: true })).toBe(null) + }) + + it('still requires schedule for script-only jobs', () => { + expect(validateCronEditor({ prompt: '', schedule: '', scriptOnlyJob: true })).toBe('schedule') + }) +}) + +describe('cronEditorUpdates', () => { + it('omits prompt when saving a script-only job with an empty prompt', () => { + expect( + cronEditorUpdates( + { deliver: 'local', name: 'Weekly', prompt: '', schedule: '0 9 * * 1' }, + { scriptOnlyJob: true } + ) + ).toEqual({ + deliver: 'local', + name: 'Weekly', + schedule: '0 9 * * 1' + }) + }) + + it('includes prompt when the user typed one on a script-only job', () => { + expect( + cronEditorUpdates( + { deliver: 'email', name: 'Weekly', prompt: 'note', schedule: '0 9 * * 1' }, + { scriptOnlyJob: true } + ).prompt + ).toBe('note') + }) +}) diff --git a/apps/desktop/src/app/cron/cron-job-model.ts b/apps/desktop/src/app/cron/cron-job-model.ts new file mode 100644 index 00000000000..38d3be879f7 --- /dev/null +++ b/apps/desktop/src/app/cron/cron-job-model.ts @@ -0,0 +1,62 @@ +import type { CronJob, CronJobUpdates } from '@/types/hermes' + +const asText = (value: unknown): string => (typeof value === 'string' ? value : '') + +/** Script-only cron jobs run a shell script on schedule with no LLM prompt. */ +export function jobIsScriptOnly(job: Pick<CronJob, 'no_agent' | 'script'>): boolean { + return Boolean(job.no_agent) && Boolean(asText(job.script).trim()) +} + +export type CronEditorValidationError = 'prompt' | 'prompt_and_schedule' | 'schedule' + +export interface CronEditorValidationInput { + prompt: string + schedule: string + scriptOnlyJob: boolean +} + +export function validateCronEditor(input: CronEditorValidationInput): CronEditorValidationError | null { + const trimmedPrompt = input.prompt.trim() + const trimmedSchedule = input.schedule.trim() + + if (!trimmedSchedule && !trimmedPrompt && !input.scriptOnlyJob) { + return 'prompt_and_schedule' + } + + if (!trimmedSchedule) { + return 'schedule' + } + + if (!input.scriptOnlyJob && !trimmedPrompt) { + return 'prompt' + } + + return null +} + +export interface CronEditorSaveValues { + deliver: string + name: string + prompt: string + schedule: string +} + +/** Build the API update payload, preserving an empty prompt on script-only jobs. */ +export function cronEditorUpdates( + values: CronEditorSaveValues, + options: { scriptOnlyJob: boolean } +): CronJobUpdates { + const updates: CronJobUpdates = { + deliver: values.deliver, + name: values.name, + schedule: values.schedule.trim() + } + + const trimmedPrompt = values.prompt.trim() + + if (!options.scriptOnlyJob || trimmedPrompt) { + updates.prompt = trimmedPrompt + } + + return updates +} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index a3d229ac5af..2e9c65d5f5a 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -55,6 +55,7 @@ import { import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import { jobState, jobTitle, STATE_DOT } from './job-state' +import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model' const DEFAULT_DELIVER = 'local' @@ -396,12 +397,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt updateCronJobs(rows => [...rows, created]) notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) }) } else if (editor.mode === 'edit') { - const updated = await updateCronJob(editor.job.id, { - prompt: values.prompt, - schedule: values.schedule, - name: values.name, - deliver: values.deliver - }) + const scriptOnlyJob = jobIsScriptOnly(editor.job) + const updated = await updateCronJob( + editor.job.id, + cronEditorUpdates(values, { scriptOnlyJob }) + ) updateCronJobs(rows => rows.map(row => (row.id === updated.id ? updated : row))) notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) }) @@ -712,6 +712,7 @@ function CronEditorDialog({ const open = editor.mode !== 'closed' const isEdit = editor.mode === 'edit' const initial = isEdit ? editor.job : null + const scriptOnlyJob = initial ? jobIsScriptOnly(initial) : false const [name, setName] = useState('') const [prompt, setPrompt] = useState('') @@ -755,11 +756,20 @@ function CronEditorDialog({ async function handleSubmit(event: React.FormEvent) { event.preventDefault() - const trimmedPrompt = prompt.trim() - const trimmedSchedule = schedule.trim() + const validationError = validateCronEditor({ + prompt, + schedule, + scriptOnlyJob + }) - if (!trimmedPrompt || !trimmedSchedule) { - setError(c.promptScheduleRequired) + if (validationError) { + setError( + validationError === 'schedule' + ? c.scheduleRequired + : validationError === 'prompt' + ? c.promptRequired + : c.promptScheduleRequired + ) return } @@ -771,8 +781,8 @@ function CronEditorDialog({ await onSave({ deliver, name: name.trim(), - prompt: trimmedPrompt, - schedule: trimmedSchedule + prompt: prompt.trim(), + schedule: schedule.trim() }) } catch (err) { setError(err instanceof Error ? err.message : c.failedSave) @@ -790,6 +800,12 @@ function CronEditorDialog({ </DialogHeader> <form className="grid gap-4" onSubmit={handleSubmit}> + {scriptOnlyJob && initial && ( + <FieldHint> + {c.scriptOnlyEditHint} <span className="font-mono">{initial.id}</span> + </FieldHint> + )} + <Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}> <Input autoFocus @@ -800,7 +816,12 @@ function CronEditorDialog({ /> </Field> - <Field htmlFor="cron-prompt" label={c.promptLabel}> + <Field + htmlFor="cron-prompt" + label={c.promptLabel} + optional={scriptOnlyJob} + optionalLabel={c.optional} + > <Textarea className="min-h-24 font-mono" id="cron-prompt" diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 4b8c249bced..9f06facef26 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -47,7 +47,7 @@ import { } from '../store/pet-overlay' import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '../store/profile' -import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects' +import { $startWorkSessionRequest, followActiveSessionCwd } from '../store/projects' import { $reviewOpen, REVIEW_PANE_ID } from '../store/review' import { $activeSessionId, @@ -65,8 +65,6 @@ import { sessionPinId, setAwaitingResponse, setBusy, - setCurrentBranch, - setCurrentCwd, setCurrentModel, setCurrentProvider, setMessages, @@ -117,6 +115,7 @@ import { useRouteResume } from './session/hooks/use-route-resume' import { useSessionActions } from './session/hooks/use-session-actions' import { useSessionListActions } from './session/hooks/use-session-list-actions' import { useSessionStateCache } from './session/hooks/use-session-state-cache' +import { startWorkspaceSession } from './session/workspace-session-target' import { AppShell } from './shell/app-shell' import { useOverlayRouting } from './shell/hooks/use-overlay-routing' import { useStatusSnapshot } from './shell/hooks/use-status-snapshot' @@ -240,6 +239,7 @@ export function DesktopController() { const { activeSessionIdRef, ensureSessionState, + resetViewSync, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef, @@ -620,6 +620,7 @@ export function DesktopController() { getRouteToken, navigate, requestGateway, + resetViewSync, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, selectedStoredSessionIdRef, @@ -735,42 +736,16 @@ export function DesktopController() { const startSessionInWorkspace = useCallback( (path: null | string) => { - startFreshSessionDraft() - - // A worktree lane carries its own path; the trunk "+" can be path-less (the - // main checkout is implicit), so fall back to the active project's root - // instead of no-op'ing on null — that was "+ on main does nothing". - const target = path?.trim() || resolveNewSessionCwd() - - if (!target) { - return - } - - // The next message creates the backend session in $currentCwd, so seed - // it (and the branch) from the workspace the user clicked the + on. - setCurrentCwd(target) - void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) - .then(info => { - const resolved = info.cwd || target - - setCurrentCwd(resolved) - setCurrentBranch(info.branch || '') - - // An EXPLICIT target (a worktree/lane path — e.g. just-created via - // "convert a branch" / "new worktree") drills the sidebar into that - // project so the new lane is visible at once. Without this, a brand-new - // worktree session is invisible from the all-projects overview (the - // live overlay skips `.worktrees` rows, and the session.info cwd-follow - // only fires on a same-session move, not a fresh session). The - // path-less trunk "+" keeps the current scope untouched. - if (path?.trim()) { - restoreWorktree(resolved) - void followActiveSessionCwd(resolved) - } - }) - .catch(() => undefined) + startWorkspaceSession({ + activeSessionIdRef, + followActiveSessionCwd, + onExplicitWorkspace: restoreWorktree, + path, + requestGateway, + startFreshSessionDraft + }) }, - [requestGateway, startFreshSessionDraft] + [activeSessionIdRef, requestGateway, startFreshSessionDraft] ) // Composer "branch off into a new worktree": the composer already created the @@ -812,6 +787,7 @@ export function DesktopController() { branchCurrentSession: branchInNewChat, busyRef, createBackendSessionForSend, + getRouteToken, handleSkinCommand, openMemoryGraph: openStarmap, refreshSessions, @@ -847,7 +823,7 @@ export function DesktopController() { // window's gateway (the overlay has none) so it survives restart. setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale)) // Mail icon: $sessions is ordered most-recent-first; the pet is global (not - // per session) so "most recent" is the right target. main.cjs already raised + // per session) so "most recent" is the right target. main.ts already raised // the window before forwarding this. setPetOverlayOpenAppHandler(() => { const recent = $sessions.get()[0] diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index eb893c3675a..2672e95a676 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -97,6 +97,7 @@ function fakeDesktop() { })), onBootProgress: vi.fn(() => () => undefined), onBackendExit: vi.fn(() => () => undefined), + onConnectionApplied: vi.fn(() => () => undefined), onPowerResume: vi.fn(() => () => undefined), onWindowStateChanged: vi.fn(() => () => undefined), touchBackend: vi.fn(async () => undefined), diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 26a4e2ce7c8..2f239569f15 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -23,6 +23,7 @@ import { setPrimaryGateway, touchSecondaryGateways } from '@/store/gateway' +import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from '@/store/gateway-switch' import { notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile' import { @@ -130,7 +131,7 @@ export function useGatewayBoot({ } const attemptReconnect = async () => { - if (cancelled || reconnecting || gatewayOpen()) { + if (cancelled || reconnecting || gatewayOpen() || $gatewaySwitching.get()) { return } @@ -181,7 +182,7 @@ export function useGatewayBoot({ } finally { reconnecting = false - if (!cancelled && !gatewayOpen()) { + if (!cancelled && !gatewayOpen() && !$gatewaySwitching.get()) { if (reconnectAttempt >= RECONNECT_ESCALATE_AFTER && !escalated) { escalated = true failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost')) @@ -193,7 +194,7 @@ export function useGatewayBoot({ } function scheduleReconnect() { - if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen()) { + if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen() || $gatewaySwitching.get()) { return } @@ -207,7 +208,7 @@ export function useGatewayBoot({ } const reconnectNow = () => { - if (cancelled || !bootCompleted) { + if (cancelled || !bootCompleted || $gatewaySwitching.get()) { return } @@ -221,7 +222,98 @@ export function useGatewayBoot({ } } - const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload)) + // Adopt the profile the primary (window) backend booted as, so same-profile + // resumes are no-op swaps and reconnects target the right backend. + // Best-effort: a missing preference means "default". Shared by boot + soft + // switch. + async function adoptPrimaryProfile() { + try { + const pref = await desktop.profile?.get?.() + const profileKey = (pref?.profile ?? '').trim() || 'default' + $activeGatewayProfile.set(profileKey) + setPrimaryGateway(gateway, profileKey) + void ensureGatewayForProfile(profileKey) + } catch { + $activeGatewayProfile.set('default') + } + } + + // Seed the working dir from the backend default on a fresh view (nothing + // open yet). Shared by boot + soft switch. + async function seedDefaultCwd() { + await ensureDefaultWorkspaceCwd() + const remoteDefault = await desktopDefaultCwd().catch(() => null) + + if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) { + setCurrentCwd(remoteDefault.cwd) + setCurrentBranch(remoteDefault.branch || '') + } + } + + // Soft gateway-mode apply: main tore down the primary without reloading. + // Wipe session lists so skeletons retrigger, then re-dial in place. + const softSwitch = async () => { + if (cancelled) { + return + } + + $gatewaySwitching.set(true) + clearReconnectTimer() + reconnectAttempt = 0 + escalated = false + reauthNotified = false + wipeSessionListsForGatewaySwitch() + + try { + gateway.close() + closeSecondaryGateways() + + const conn = await desktop.getConnection() + + if (cancelled) { + return + } + + publish(conn) + const wsUrl = await resolveGatewayWsUrl(desktop, conn) + await gateway.connect(wsUrl) + + if (cancelled) { + return + } + + await adoptPrimaryProfile() + await seedDefaultCwd() + await callbacksRef.current.refreshHermesConfig().catch(() => undefined) + await callbacksRef.current.refreshSessions().catch(() => undefined) + completeDesktopBoot() + bootCompleted = true + } catch (err) { + if (!cancelled) { + const message = err instanceof Error ? err.message : String(err) + failDesktopBoot(message) + notifyError(err, translateNow('boot.errors.desktopBootFailed')) + setSessionsLoading(false) + } + } finally { + $gatewaySwitching.set(false) + } + } + + const offBootProgress = desktop.onBootProgress(payload => { + // Soft switch / post-boot startHermes re-emits progress — ignore so the + // cold-boot CONNECTING overlay stays down. Errors still surface. + if ($gatewaySwitching.get() || bootCompleted) { + if (payload.error) { + applyDesktopBootProgress(payload) + } + + return + } + + applyDesktopBootProgress(payload) + }) + void desktop .getBootProgress() .then(snapshot => applyDesktopBootProgress(snapshot)) @@ -258,18 +350,20 @@ export function useGatewayBoot({ if (bootCompleted) { completeDesktopBoot() } - } else if (bootCompleted && (st === 'closed' || st === 'error')) { + } else if (bootCompleted && !$gatewaySwitching.get() && (st === 'closed' || st === 'error')) { // The socket dropped after a healthy boot (typically sleep/wake). Try // to bring it back instead of leaving the composer stuck disabled. scheduleReconnect() } }) - const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event)) + const sourceProfile = normalizeProfileKey($activeGatewayProfile.get()) + const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile })) // Wake signals: power resume (macOS/Windows), network coming back, and the // window regaining focus/visibility. Each nudges an immediate reconnect. const offPowerResume = desktop.onPowerResume?.(() => reconnectNow()) + const offConnectionApplied = desktop.onConnectionApplied?.(() => void softSwitch()) const onOnline = () => reconnectNow() @@ -319,6 +413,10 @@ export function useGatewayBoot({ }) const offExit = desktop.onBackendExit(() => { + if ($gatewaySwitching.get()) { + return + } + if ($desktopBoot.get().running || $desktopBoot.get().visible) { failDesktopBoot(translateNow('boot.errors.backgroundExitedDuringStartup')) } @@ -357,31 +455,14 @@ export function useGatewayBoot({ return } - // Record which profile the primary (window) backend booted as, so - // same-profile resumes are no-op swaps and any reconnect targets the - // right backend. Best-effort: a missing preference means "default". - try { - const pref = await desktop.profile?.get?.() - const profileKey = (pref?.profile ?? '').trim() || 'default' - $activeGatewayProfile.set(profileKey) - setPrimaryGateway(gateway, profileKey) - void ensureGatewayForProfile(profileKey) - } catch { - $activeGatewayProfile.set('default') - } + await adoptPrimaryProfile() setDesktopBootStep({ phase: 'renderer.config', message: translateNow('boot.steps.loadingSettings'), progress: 97 }) - await ensureDefaultWorkspaceCwd() - const remoteDefault = await desktopDefaultCwd().catch(() => null) - - if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) { - setCurrentCwd(remoteDefault.cwd) - setCurrentBranch(remoteDefault.branch || '') - } + await seedDefaultCwd() await callbacksRef.current.refreshHermesConfig() @@ -411,6 +492,7 @@ export function useGatewayBoot({ return () => { cancelled = true + $gatewaySwitching.set(false) clearReconnectTimer() clearInterval(keepaliveTimer) offWorking() @@ -419,6 +501,7 @@ export function useGatewayBoot({ window.removeEventListener('online', onOnline) document.removeEventListener('visibilitychange', onVisible) offPowerResume?.() + offConnectionApplied?.() offState() offEvent() offExit() diff --git a/apps/desktop/src/app/messaging/index.test.tsx b/apps/desktop/src/app/messaging/index.test.tsx index a7d9273c0c9..b078a2043b1 100644 --- a/apps/desktop/src/app/messaging/index.test.tsx +++ b/apps/desktop/src/app/messaging/index.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -53,12 +53,16 @@ afterEach(() => { async function renderMessaging() { const { MessagingView } = await import('./index') + let result: ReturnType<typeof render> + await act(async () => { + result = render( + <MemoryRouter> + <MessagingView /> + </MemoryRouter> + ) + }) - return render( - <MemoryRouter> - <MessagingView /> - </MemoryRouter> - ) + return result! } describe('MessagingView setup-guide link', () => { @@ -82,7 +86,9 @@ describe('MessagingView setup-guide link', () => { await renderMessaging() const link = await screen.findByText('Open setup guide') - fireEvent.click(link) + await act(async () => { + fireEvent.click(link) + }) await waitFor(() => expect(openExternalLink).toHaveBeenCalledWith(docsUrl)) }) diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx index ae3c1860b7c..d635186e34e 100644 --- a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx +++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx @@ -1,6 +1,7 @@ import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef, useState } from 'react' +import { PetHeartField, playVibeHearts } from '@/components/chat/vibe-hearts' import { PetBubble } from '@/components/pet/pet-bubble' import { PetSprite } from '@/components/pet/pet-sprite' import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture' @@ -72,6 +73,8 @@ export function PetOverlayApp() { const zoomAnchorRef = useRef<PetZoomAnchor | null>(null) const petRef = useRef<HTMLDivElement | null>(null) const inputRef = useRef<HTMLInputElement | null>(null) + // Last mirrored reaction id — a bump means the main window fired a reaction. + const lastReactionRef = useRef<number | null>(null) const ignoreRef = useRef(true) const composerOpenRef = useRef(false) const clickTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined) @@ -91,6 +94,19 @@ export function PetOverlayApp() { setBusy(Boolean(payload.busy)) setAwaitingResponse(Boolean(payload.awaiting)) setUnread(Boolean(payload.unread)) + + // Play a reaction on a new id (ignore the first sync, which just primes it). + const reaction = payload.reaction ?? null + + if (lastReactionRef.current === null) { + lastReactionRef.current = reaction?.id ?? 0 + } else if (reaction && reaction.id > lastReactionRef.current) { + lastReactionRef.current = reaction.id + + if (reaction.kind === 'vibe') { + playVibeHearts() + } + } }) // Tell the main renderer we're mounted so it pushes the current frame (the @@ -416,6 +432,12 @@ export function PetOverlayApp() { <div style={{ lineHeight: 0, position: 'relative' }}> <PetSprite info={info} /> + {/* Hearts on the popped-out pet — identical to in-window. */} + <PetHeartField + petH={(info.frameH ?? DEFAULT_FRAME_H) * (info.scale ?? DEFAULT_SCALE)} + petW={(info.frameW ?? DEFAULT_FRAME_W) * (info.scale ?? DEFAULT_SCALE)} + /> + {/* Mail icon: only when a finish landed while you were away. Jumps to the app's most recent thread. Anchored to the sprite (kept inside its box so the overlay's click-through hit-test still catches it); diff --git a/apps/desktop/src/app/right-sidebar/terminal/instance.tsx b/apps/desktop/src/app/right-sidebar/terminal/instance.tsx index 399407d8169..933916b640e 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/instance.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/instance.tsx @@ -19,12 +19,20 @@ interface TerminalInstanceProps { cwd: string active: boolean onAddSelectionToChat: (text: string, label?: string) => void + restoreCwd?: string reviveBuffer?: string } /** One persistent xterm+PTY. Every open tab stays mounted (so its shell and * scrollback survive tab switches); only the active one is shown. */ -export function TerminalInstance({ id, active, cwd, onAddSelectionToChat, reviveBuffer }: TerminalInstanceProps) { +export function TerminalInstance({ + id, + active, + cwd, + onAddSelectionToChat, + restoreCwd, + reviveBuffer +}: TerminalInstanceProps) { const { t } = useI18n() const { addSelectionToChat, hostRef, selection, selectionStyle, status } = useTerminalSession({ @@ -32,6 +40,7 @@ export function TerminalInstance({ id, active, cwd, onAddSelectionToChat, revive cwd, active, onAddSelectionToChat, + restoreCwd, reviveBuffer, onShell: shell => reportTerminalShell(id, shell) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx new file mode 100644 index 00000000000..60b2113916a --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx @@ -0,0 +1,36 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { $bindings } from '@/store/keybinds' + +import { TerminalRail } from './rail' +import { $activeTerminalId, $terminals } from './terminals' + +describe('TerminalRail', () => { + beforeEach(() => { + $terminals.set([{ auto: true, cwd: 'C:\\repo', id: 'term-1', kind: 'user', title: 'PowerShell' }]) + $activeTerminalId.set('term-1') + $bindings.set({ ...$bindings.get(), 'view.showTerminal': ['ctrl+`'] }) + }) + + afterEach(() => { + cleanup() + $terminals.set([]) + $activeTerminalId.set(null) + }) + + it('keeps a hotkey label inline inside the portaled tooltip decoration', async () => { + const view = render(<TerminalRail />) + + fireEvent.pointerMove(screen.getByRole('tab', { name: '1. PowerShell' }), { pointerType: 'mouse' }) + await screen.findByRole('tooltip') + + const content = document.querySelector<HTMLElement>('[data-slot="tooltip-content"]') + const label = content?.firstElementChild?.firstElementChild + + expect(content).not.toBeNull() + expect(view.container.contains(content)).toBe(false) + expect(label?.classList.contains('inline-flex')).toBe(true) + expect(label?.classList.contains('flex')).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx index c5a07bb8e6d..8a81a16925b 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx @@ -8,7 +8,7 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu' -import { Tip } from '@/components/ui/tooltip' +import { Tip, TipHintLabel } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' @@ -30,18 +30,6 @@ import { const RAIL_ACTION = 'grid size-6 place-items-center rounded text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring [-webkit-app-region:no-drag]' -/** Tooltip label with a trailing hotkey hint (the user's live binding). */ -function hintLabel(text: string, combo?: string) { - return combo ? ( - <span className="flex items-center gap-2"> - <span>{text}</span> - <span className="opacity-55">{formatCombo(combo)}</span> - </span> - ) : ( - text - ) -} - /** Thin icon "bookmark" strip blended into the terminal surface, shown whenever a * terminal exists. Each square is a tab (name + hotkey on hover); close via the * shell's `exit`, middle-click, or the context menu. */ @@ -78,7 +66,10 @@ export function TerminalRail() { /> ))} <li className="flex w-full justify-center"> - <Tip label={hintLabel(t.rightSidebar.terminalNew, newHint)} side="left"> + <Tip + label={<TipHintLabel hint={newHint && formatCombo(newHint)} text={t.rightSidebar.terminalNew} />} + side="left" + > <button aria-label={t.rightSidebar.terminalNew} className={cn(RAIL_ACTION, 'size-7 text-(--ui-text-quaternary)')} @@ -129,7 +120,7 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T className="absolute inset-y-0.5 right-0 w-0.5 rounded-l-sm bg-(--ui-stroke-primary)" /> )} - <Tip label={hintLabel(label, toggleHint)} side="left"> + <Tip label={<TipHintLabel hint={toggleHint && formatCombo(toggleHint)} text={label} />} side="left"> <button aria-label={label} aria-selected={active} diff --git a/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts b/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts new file mode 100644 index 00000000000..9e02179368e --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' + +import { cleanReviveSnapshot, isIdlePromptOnly, parseOscCwd } from './use-terminal-session' + +// A default-PowerShell idle prompt: no blank-line separator before it. +const PS_PROMPT = 'PS C:\\Users\\Aleksandr>' + +describe('isIdlePromptOnly', () => { + it('is true for an empty or whitespace-only buffer', () => { + expect(isIdlePromptOnly('')).toBe(true) + expect(isIdlePromptOnly('\r\n \r\n')).toBe(true) + }) + + it('is true for a lone prompt line', () => { + expect(isIdlePromptOnly(PS_PROMPT)).toBe(true) + }) + + it('is true for a buffer that is only repeated identical prompts (accumulation)', () => { + expect(isIdlePromptOnly([PS_PROMPT, PS_PROMPT, PS_PROMPT].join('\r\n'))).toBe(true) + }) + + it('ignores blank gaps between repeated prompts (the "gapped" variant)', () => { + expect(isIdlePromptOnly([PS_PROMPT, '', '', PS_PROMPT].join('\r\n'))).toBe(true) + }) + + it('is false when the buffer holds a real command and output', () => { + expect(isIdlePromptOnly([PS_PROMPT, 'cd project', 'PS C:\\Users\\Aleksandr\\project>'].join('\r\n'))).toBe(false) + }) + + it('is false when two different prompts are present (cwd actually changed)', () => { + expect(isIdlePromptOnly([PS_PROMPT, 'PS C:\\Users\\Aleksandr\\project>'].join('\r\n'))).toBe(false) + }) +}) + +describe('cleanReviveSnapshot', () => { + it('drops a spaced trailing prompt block after a blank separator (starship)', () => { + const snapshot = ['echo hi', 'hi', '', PS_PROMPT].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe('echo hi\r\nhi') + }) + + it('drops a multi-line prompt block after a blank separator (powerline)', () => { + const snapshot = ['work', '', '┌─ user@host ~/project', '└─$'].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe('work') + }) + + it('drops a single-line trailing prompt with no preceding blank line (PowerShell)', () => { + // Default PowerShell prints no blank line before its prompt; the fresh shell + // reprints it on boot, so the redundant idle prompt must be trimmed here. + const snapshot = ['echo hi', 'hi', PS_PROMPT].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe('echo hi\r\nhi') + }) + + it('keeps command output and drops only the trailing prompt on a long history', () => { + const history = ['cmd1', 'out1', 'cmd2', 'out2'] + const snapshot = [...history, PS_PROMPT].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe(history.join('\r\n')) + }) + + it('reduces a lone prompt to an empty buffer', () => { + expect(cleanReviveSnapshot(PS_PROMPT)).toBe('') + expect(cleanReviveSnapshot([PS_PROMPT, '', ''].join('\r\n'))).toBe('') + }) + + it('returns empty for a blank-only buffer without throwing', () => { + expect(cleanReviveSnapshot('')).toBe('') + expect(cleanReviveSnapshot('\r\n \r\n')).toBe('') + }) +}) + +describe('parseOscCwd', () => { + it('parses an OSC 7 file URI and percent-decodes it', () => { + expect(parseOscCwd(7, 'file://host/Users/al/my%20project')).toBe('/Users/al/my project') + }) + + it('strips the leading slash from a Windows OSC 7 file URI', () => { + expect(parseOscCwd(7, 'file:///C:/Users/Aleksandr/project')).toBe('C:/Users/Aleksandr/project') + }) + + it('ignores non-file OSC 7 payloads', () => { + expect(parseOscCwd(7, 'https://example.com')).toBeNull() + expect(parseOscCwd(7, '')).toBeNull() + }) + + it('parses an OSC 9;9 cwd payload and unquotes it', () => { + expect(parseOscCwd(9, '9;"C:\\Users\\Aleksandr"')).toBe('C:\\Users\\Aleksandr') + expect(parseOscCwd(9, '9;/home/al/src')).toBe('/home/al/src') + }) + + it('ignores OSC 9 sub-commands other than 9;<path> (e.g. progress)', () => { + expect(parseOscCwd(9, '4;3')).toBeNull() + expect(parseOscCwd(9, 'some notification')).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts b/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts index b04e1e12710..eb99e7a7821 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts @@ -87,4 +87,37 @@ describe('terminal store persistence', () => { closeAllTerminals() expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull() }) + + it('restores and persists the last observed cwd so a reopened tab lands where the user cd-d', async () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + activeTerminalId: 'term-one', + terminals: [{ auto: false, cwd: '/repo', id: 'term-one', restoreCwd: '/repo/packages/api', title: 'zsh' }] + }) + ) + + const { $terminals, updateTerminalRestoreCwd } = await loadTerminalStore() + + expect($terminals.get()[0]?.restoreCwd).toBe('/repo/packages/api') + + updateTerminalRestoreCwd('term-one', '/repo/packages/web') + expect($terminals.get()[0]?.restoreCwd).toBe('/repo/packages/web') + expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}').terminals[0].restoreCwd).toBe( + '/repo/packages/web' + ) + }) + + it('never attaches a restore cwd to an agent tab and ignores empty values', async () => { + const { $terminals, createTerminal, ensureAgentTerminal, updateTerminalRestoreCwd } = await loadTerminalStore() + + const userId = createTerminal('/repo') + const agentId = ensureAgentTerminal('proc-1', 'background task')! + + updateTerminalRestoreCwd(agentId, '/somewhere') + updateTerminalRestoreCwd(userId, ' ') + + expect($terminals.get().find(term => term.id === agentId)?.restoreCwd).toBeUndefined() + expect($terminals.get().find(term => term.id === userId)?.restoreCwd).toBeUndefined() + }) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts index 2d716242ab3..c86f57aeeae 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts @@ -19,6 +19,10 @@ export interface TerminalEntry { * (the project root if opened in one, else the backend's default). Switching * sessions never moves or recreates a terminal. */ cwd: string + /** Last observed working directory of the live shell (tracked via the PTY + * cwd probe / OSC 7). Used to reopen the tab where the user last `cd`'d + * rather than the original launch dir. User tabs only. */ + restoreCwd?: string /** Serialized xterm scrollback from the last session, replayed on relaunch so * the tab reopens with its recent history (VS Code parity). Processes are NOT * revived — a fresh shell starts beneath the restored buffer. Captured live @@ -34,6 +38,7 @@ interface PersistedTerminalEntry { auto: boolean cwd: string id: string + restoreCwd?: string reviveBuffer?: string title: string } @@ -59,6 +64,7 @@ function sanitizePersistedTerminal(value: unknown): PersistedTerminalEntry | nul const id = typeof record.id === 'string' ? record.id.trim() : '' const title = typeof record.title === 'string' ? record.title.trim() : '' const cwd = typeof record.cwd === 'string' ? record.cwd : '' + const restoreCwd = typeof record.restoreCwd === 'string' && record.restoreCwd ? record.restoreCwd : undefined const reviveBuffer = typeof record.reviveBuffer === 'string' ? record.reviveBuffer : undefined if (!id) { @@ -69,6 +75,7 @@ function sanitizePersistedTerminal(value: unknown): PersistedTerminalEntry | nul auto: typeof record.auto === 'boolean' ? record.auto : true, cwd, id, + ...(restoreCwd ? { restoreCwd } : {}), ...(reviveBuffer ? { reviveBuffer } : {}), title: title || 'Terminal' } @@ -116,6 +123,7 @@ function persistTerminals(list: readonly TerminalEntry[], activeTerminalId: null auto: term.auto, cwd: term.cwd, id: term.id, + ...(term.restoreCwd ? { restoreCwd: term.restoreCwd } : {}), ...(term.reviveBuffer ? { reviveBuffer: term.reviveBuffer } : {}), title: term.title })) @@ -309,6 +317,27 @@ export function updateTerminalReviveBuffer(id: string, reviveBuffer: string): vo ) } +/** Record the shell's latest working directory for a tab so the next launch can + * restart the PTY there instead of the original launch dir. User tabs only; + * no-ops when the value is empty or unchanged to avoid redundant persistence. */ +export function updateTerminalRestoreCwd(id: string, restoreCwd: string): void { + const next = restoreCwd.trim() + + if (!next) { + return + } + + $terminals.set( + $terminals.get().map(term => { + if (term.id !== id || term.kind !== 'user' || term.restoreCwd === next) { + return term + } + + return { ...term, restoreCwd: next } + }) + ) +} + export function renameTerminal(id: string, title: string): void { const trimmed = title.trim() diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 931b8fec624..c1b09487c6f 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -21,7 +21,7 @@ import { terminalSelectionLabel, terminalTheme } from './selection' -import { closeTerminal, updateTerminalReviveBuffer } from './terminals' +import { closeTerminal, updateTerminalRestoreCwd, updateTerminalReviveBuffer } from './terminals' // How many scrollback lines to serialize for relaunch restore. Mirrors VS Code's // terminal.integrated.persistentSessionScrollback default; the store caps the @@ -33,6 +33,11 @@ const PERSISTENT_SESSION_SCROLLBACK = 200 // renderer tears down), then at most once per window while output streams. const SNAPSHOT_THROTTLE_MS = 750 +// Minimum gap between main-side PTY cwd probes. The probe spawns lsof on macOS, +// so keep it well throttled — cwd only changes on a `cd`, which the reporter +// already reads off the next output snapshot anyway. +const CWD_PROBE_THROTTLE_MS = 2000 + // True once the page/app is tearing down (Cmd+Q, Alt+F4, window close, reload). // App quit kills the PTYs from the main process, which fires onExit in the // renderer — but React skips effect cleanups on teardown, so the per-instance @@ -168,38 +173,55 @@ function stripInitialPromptGap(data: string) { return prefix } +// A row's content with ANSI escapes and all whitespace stripped — '' for a +// spacer / prompt-gap / zsh `%` marker row. +const visibleText = (line: string) => stripEscapeSequences(line).replace(/[\s%]/g, '') + // Trim the shell's trailing idle prompt from a serialized snapshot before it's // persisted. Without it, the saved buffer ends in the old prompt, so the next -// launch replays it directly above the fresh shell's prompt ("double bar"). The -// prompt is the short block after the last blank line (starship's add_newline -// gap); only a short tail is dropped, so real command output is never trimmed and -// configs without that blank line simply keep the historical prompt (no loss). -function cleanReviveSnapshot(serialized: string): string { - const visible = (line: string) => stripEscapeSequences(line).replace(/[\s%]/g, '') +// launch replays it directly above the fresh shell's prompt ("double bar"). +// +// An interactive shell always reprints its prompt after a command finishes, so +// the tail of an idle buffer is the prompt, never real history. Two prompt +// shapes exist: +// - Spaced/multi-line (starship add_newline, powerline): a blank line sits +// just above the prompt, so the short block after the last blank is dropped. +// - Single-line (default PowerShell `PS C:\..>`, bash `user@host:~$`): no blank +// separator, so the final line itself is the prompt and is dropped. +// The fresh shell reprints the current prompt on boot either way, so only the +// redundant idle prompt is removed — command output is preserved. +export function cleanReviveSnapshot(serialized: string): string { const lines = serialized.split(/\r?\n/) - while (lines.length && visible(lines[lines.length - 1]) === '') { + while (lines.length && !visibleText(lines[lines.length - 1])) { lines.pop() } - let lastBlank = -1 - - for (let i = lines.length - 1; i >= 0; i -= 1) { - if (visible(lines[i]) === '') { - lastBlank = i - - break - } + if (lines.length === 0) { + return '' } - // A prompt is a short block; a long tail after the blank is real output, leave it. - if (lastBlank >= 0 && lines.length - 1 - lastBlank <= 3) { - lines.length = lastBlank - } + const lastBlank = lines.findLastIndex(line => !visibleText(line)) + const spacedPrompt = lastBlank >= 0 && lines.length - 1 - lastBlank <= 3 + + // Spaced prompt (starship/powerline): drop the block after the blank + // separator. Otherwise the last line is the single-line prompt itself. + lines.length = spacedPrompt ? lastBlank : lines.length - 1 return lines.join('\r\n') } +// True when a revive buffer holds no real scrollback: empty, or only repeats of +// one line (the idle prompt). This is the idle-accumulation signature (#61572) — +// each relaunch replayed the saved prompt(s) and the fresh shell printed one more +// below, growing the tab by a line per cycle. Real sessions vary (prompt + +// command + output), so genuine short histories are never mistaken for idle. +export function isIdlePromptOnly(serialized: string): boolean { + const lines = serialized.split(/\r?\n/).map(visibleText).filter(Boolean) + + return lines.length === 0 || lines.every(line => line === lines[0]) +} + interface UseTerminalSessionOptions { /** Renderer-side terminal id (the tab handle), used to key the agent reader. */ id: string @@ -207,12 +229,52 @@ interface UseTerminalSessionOptions { /** Only the active tab is visible, owns the agent reader, and runs injections. */ active: boolean onAddSelectionToChat: (text: string, label?: string) => void + /** Last observed shell cwd from the previous session; the fresh PTY starts + * here (falling back to `cwd`) so a prior `cd` survives a relaunch. */ + restoreCwd?: string /** Serialized scrollback from the previous session, replayed once on mount. */ reviveBuffer?: string /** Reports the resolved shell name once the PTY is live (for the tab label). */ onShell?: (shell: string) => void } +// Parse a working directory out of a cwd-reporting OSC payload. Covers OSC 7 +// (`file://host/path`, emitted by many bash/zsh integrations) and OSC 9;9 +// (`9;<path>`, ConEmu/Windows-Terminal style some PowerShell profiles emit). +// Returns null for anything unrecognized so callers can ignore it. +export function parseOscCwd(code: 7 | 9, payload: string): string | null { + if (code === 9) { + // OSC 9;9;<path> — the leading "9;" selects the cwd sub-command. + if (!payload.startsWith('9;')) { + return null + } + + const raw = payload.slice(2).trim().replace(/^"|"$/g, '') + + return raw || null + } + + // OSC 7 — a file URI. Strip the scheme + authority and percent-decode. + const match = /^file:\/\/[^/]*(\/.*)$/.exec(payload.trim()) + + if (!match) { + return null + } + + let raw = match[1] + + try { + raw = decodeURIComponent(raw) + } catch { + // Keep the undecoded path if it isn't valid percent-encoding. + } + + // Windows file URIs carry a leading slash before the drive (`/C:/Users`). + const windows = /^\/[A-Za-z]:[\\/]/.exec(raw) + + return (windows ? raw.slice(1) : raw) || null +} + // Bind the palette to the live skin surface so the terminal blends with the app // (and the contrast clamp has a real background to work against). function withSurface(theme: ReturnType<typeof terminalTheme>) { @@ -314,6 +376,7 @@ export function useTerminalSession({ cwd, active, onAddSelectionToChat, + restoreCwd, reviveBuffer, onShell }: UseTerminalSessionOptions) { @@ -336,6 +399,15 @@ export function useTerminalSession({ // Snapshot the revive buffer once: live snapshots feed updateTerminalReviveBuffer // and would otherwise re-arm replay on every store-driven re-render. const initialReviveBufferRef = useRef(reviveBuffer) + // The cwd to boot the fresh PTY in — the last dir the prior session observed + // (survives a `cd`), captured once so store-driven re-renders don't move it. + const initialRestoreCwdRef = useRef(restoreCwd) + // Latest cwd seen this session; de-dupes redundant store writes. + const lastObservedCwdRef = useRef<string | null>(null) + // Whether the user ever fed input into this session (keystrokes, paste, + // drag-and-drop paths, or an injected command). Gates idle-buffer handling in + // persistSnapshot so an untouched tab never re-saves an accumulating snapshot. + const hasSessionActivityRef = useRef(false) const shellNameRef = useRef('shell') const selectionLabelRef = useRef('') const selectionRef = useRef('') @@ -473,6 +545,50 @@ export function useTerminalSession({ term.write('\r\n') } + // Track the shell's working directory so a reopened tab restarts where the + // user last `cd`'d. Two independent signals feed it: cwd-reporting OSC + // sequences (immediate, for shells configured to emit them) and a periodic + // PTY cwd probe on the main side (shell-agnostic on POSIX). The store + // updater de-dupes, so both feeding it is harmless. + const recordCwd = (next: string | null | undefined) => { + const value = (next ?? '').trim() + + if (!value || value === lastObservedCwdRef.current) { + return + } + + lastObservedCwdRef.current = value + updateTerminalRestoreCwd(id, value) + } + + const cwdOscHandlers = ([7, 9] as const).map(code => + term.parser.registerOscHandler(code, payload => { + recordCwd(parseOscCwd(code, payload)) + + return false // let the sequence propagate; we only observe it + }) + ) + + cleanup.push(() => cwdOscHandlers.forEach(handler => handler.dispose())) + + let cwdProbeAt = 0 + + const probeCwd = () => { + const sessionId = sessionIdRef.current + + if (!sessionId || !terminalApi.cwd || Date.now() - cwdProbeAt < CWD_PROBE_THROTTLE_MS) { + return + } + + cwdProbeAt = Date.now() + void terminalApi + .cwd(sessionId) + .then(recordCwd) + .catch(() => { + // Best-effort: no cwd probe on this platform (e.g. Windows). + }) + } + // Capture the buffer on a leading-edge throttle and persist synchronously via // the store. No unload hook: by the time the user quits, a recent snapshot is // already on disk (the prior beforeunload-based attempt lost the last output). @@ -486,12 +602,31 @@ export function useTerminalSession({ lastSnapshotAt = Date.now() + // No user input this session: never re-serialize. The live buffer now holds + // the replayed history plus a fresh boot prompt, and re-saving that is + // exactly what grew idle tabs by one prompt line per relaunch (#61572). + // If the buffer we loaded carried no real scrollback (empty, or only a + // repeated prompt), clear it so the next launch shows a single fresh prompt + // and any pre-existing accumulation heals. Otherwise leave the prior + // snapshot untouched so real history from an earlier active session + // survives an idle reopen instead of being overwritten. + if (!hasSessionActivityRef.current) { + if (isIdlePromptOnly(initialReviveBufferRef.current ?? '')) { + updateTerminalReviveBuffer(id, '') + } + + return + } + try { const snapshot = serialize.serialize({ scrollback: PERSISTENT_SESSION_SCROLLBACK }) updateTerminalReviveBuffer(id, cleanReviveSnapshot(snapshot)) } catch { // Best-effort restore: never let serialization break a live terminal. } + + // A user command may have `cd`'d; refresh the persisted cwd (throttled). + probeCwd() } const scheduleSnapshot = () => { @@ -544,6 +679,7 @@ export function useTerminalSession({ return } + hasSessionActivityRef.current = true void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `) term.focus() triggerHaptic('selection') @@ -640,6 +776,7 @@ export function useTerminalSession({ }) const dataDisposable = term.onData(data => { + hasSessionActivityRef.current = true const id = sessionIdRef.current if (id) { @@ -661,7 +798,10 @@ export function useTerminalSession({ const startSession = () => void terminalApi - .start({ cols: term.cols, cwd, rows: term.rows }) + // Prefer the prior session's last cwd so a reopened tab lands where the + // user last `cd`'d; the main side falls back to the launch cwd (then + // home) if that dir no longer exists. + .start({ cols: term.cols, cwd: initialRestoreCwdRef.current || cwd, rows: term.rows }) .then(session => { if (disposed) { void terminalApi.dispose(session.id) @@ -846,6 +986,7 @@ export function useTerminalSession({ return } + hasSessionActivityRef.current = true void window.hermesDesktop?.terminal?.write(sessionId, `${command}\r`) $terminalInjection.set(null) termRef.current?.focus() diff --git a/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx b/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx index b8b62f50999..a15899c816b 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx @@ -56,6 +56,7 @@ export function TerminalWorkspace({ onAddSelectionToChat }: TerminalWorkspacePro id={term.id} key={term.id} onAddSelectionToChat={onAddSelectionToChat} + restoreCwd={term.restoreCwd} reviveBuffer={term.reviveBuffer} /> ) diff --git a/apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx new file mode 100644 index 00000000000..aba64c51738 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx @@ -0,0 +1,102 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { useEffect } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + $currentBranch, + $currentCwd, + $newChatWorkspaceTarget, + setCurrentBranch, + setCurrentCwd, + setCurrentCwdTransient, + setNewChatWorkspaceTarget +} from '@/store/session' + +import { useCwdActions } from './use-cwd-actions' + +type CwdActionsHandle = ReturnType<typeof useCwdActions> + +function deferred<T>() { + let resolve!: (value: T) => void + + const promise = new Promise<T>(done => { + resolve = done + }) + + return { promise, resolve } +} + +function Harness({ + activeSessionIdRef, + onReady, + requestGateway +}: { + activeSessionIdRef: MutableRefObject<string | null> + onReady: (handle: CwdActionsHandle) => void + requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> +}) { + const actions = useCwdActions({ + activeSessionId: activeSessionIdRef.current, + activeSessionIdRef, + requestGateway + }) + + useEffect(() => { + onReady(actions) + }, [actions, onReady]) + + return null +} + +describe('useCwdActions draft workspace target', () => { + beforeEach(() => { + setCurrentCwd('') + setCurrentBranch('') + setNewChatWorkspaceTarget(undefined) + }) + + afterEach(() => { + cleanup() + setCurrentCwd('') + setCurrentBranch('') + setNewChatWorkspaceTarget(undefined) + vi.restoreAllMocks() + }) + + it('ignores stale draft cwd normalization after a newer no-workspace target wins', async () => { + const projectInfo = deferred<{ branch?: string; cwd?: string }>() + const requestGateway = vi.fn(async () => projectInfo.promise as never) + const activeSessionIdRef: MutableRefObject<string | null> = { current: null } + let handle: CwdActionsHandle | null = null + + render( + <Harness + activeSessionIdRef={activeSessionIdRef} + onReady={h => (handle = h)} + requestGateway={requestGateway} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + let pendingChange!: Promise<void> + + await act(async () => { + pendingChange = handle!.changeSessionCwd('/stale-workspace') + }) + + expect($newChatWorkspaceTarget.get()).toBe('/stale-workspace') + + setNewChatWorkspaceTarget(null) + setCurrentCwdTransient('') + projectInfo.resolve({ branch: 'main', cwd: '/normalized-stale-workspace' }) + + await act(async () => { + await pendingChange + }) + + expect($newChatWorkspaceTarget.get()).toBeNull() + expect($currentCwd.get()).toBe('') + expect($currentBranch.get()).toBe('') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts index 2308191b8b1..8226a2f5950 100644 --- a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts @@ -2,7 +2,13 @@ import { type MutableRefObject, useCallback } from 'react' import { useI18n } from '@/i18n' import { notify, notifyError } from '@/store/notifications' -import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session' +import { + $currentCwd, + $newChatWorkspaceTargetGeneration, + setCurrentBranch, + setCurrentCwd, + setNewChatWorkspaceTarget +} from '@/store/session' import type { SessionRuntimeInfo } from '@/types/hermes' interface CwdActionsOptions { @@ -55,6 +61,7 @@ export function useCwdActions({ if (!activeSessionId) { setCurrentCwd(trimmed) + const workspaceGeneration = setNewChatWorkspaceTarget(trimmed) try { const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { @@ -62,15 +69,22 @@ export function useCwdActions({ cwd: trimmed }) + if ($newChatWorkspaceTargetGeneration.get() !== workspaceGeneration || activeSessionIdRef.current) { + return + } + // Adopt the backend's normalized cwd so the persisted workspace and // branch stay consistent with what the agent will use. if (info.cwd) { setCurrentCwd(info.cwd) + setNewChatWorkspaceTarget(info.cwd) } setCurrentBranch(info.branch || '') } catch { - setCurrentBranch('') + if ($newChatWorkspaceTargetGeneration.get() === workspaceGeneration && !activeSessionIdRef.current) { + setCurrentBranch('') + } } return @@ -103,7 +117,7 @@ export function useCwdActions({ }) } }, - [activeSessionId, copy, onSessionRuntimeInfo, requestGateway] + [activeSessionId, activeSessionIdRef, copy, onSessionRuntimeInfo, requestGateway] ) return { changeSessionCwd, refreshProjectBranch } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/approval-mode-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/approval-mode-event.test.tsx new file mode 100644 index 00000000000..4616bca5448 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/approval-mode-event.test.tsx @@ -0,0 +1,104 @@ +import { QueryClient } from '@tanstack/react-query' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useEffect, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' +import { createClientSessionState } from '@/lib/chat-runtime' +import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode' +import { $activeGatewayProfile } from '@/store/profile' +import type { RpcEvent } from '@/types/hermes' + +import { useMessageStream } from './index' + +const ACTIVE_SID = 'session-active' +let handleEvent: ((event: RpcEvent) => void) | null = null + +function Harness() { + const activeSessionIdRef = useRef<string | null>(ACTIVE_SID) + const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>()) + const queryClientRef = useRef(new QueryClient()) + + const stream = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession: vi.fn(async () => undefined), + queryClient: queryClientRef.current, + refreshHermesConfig: vi.fn(async () => undefined), + refreshSessions: vi.fn(async () => undefined), + sessionStateByRuntimeIdRef, + updateSessionState: (sessionId, updater) => { + const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState() + const next = updater(current) + sessionStateByRuntimeIdRef.current.set(sessionId, next) + + return next + } + }) + + useEffect(() => { + handleEvent = stream.handleGatewayEvent + }, [stream.handleGatewayEvent]) + + return null +} + +async function mountStream() { + render(<Harness />) + await waitFor(() => expect(handleEvent).not.toBeNull()) +} + +describe('live session.info approval mode reconciliation', () => { + beforeEach(() => { + handleEvent = null + $approvalModes.set({}) + $activeGatewayProfile.set('work') + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('reconciles an active-session event under its source gateway profile', async () => { + await mountStream() + + act(() => + handleEvent!({ + payload: { approval_mode: 'off' }, + profile: 'work', + session_id: ACTIVE_SID, + type: 'session.info' + }) + ) + + expect(approvalModeForProfile('work')).toBe('off') + expect(approvalModeForProfile('default')).toBe('smart') + }) + + it('ignores stale session.info from a non-active session on the active gateway', async () => { + await mountStream() + + act(() => + handleEvent!({ + payload: { approval_mode: 'off' }, + profile: 'work', + session_id: 'session-stale', + type: 'session.info' + }) + ) + + expect(approvalModeForProfile('work')).toBe('smart') + }) + + it('does not cache an event under a different active profile when its source profile is absent', async () => { + await mountStream() + $activeGatewayProfile.set('personal') + + act(() => + handleEvent!({ payload: { approval_mode: 'off' }, session_id: ACTIVE_SID, type: 'session.info' }) + ) + + expect(approvalModeForProfile('personal')).toBe('smart') + expect(approvalModeForProfile('work')).toBe('smart') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index f057bca4f24..77104ba6295 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -1,16 +1,18 @@ import type { QueryClient } from '@tanstack/react-query' -import { type MutableRefObject, useCallback } from 'react' +import { type MutableRefObject, useCallback, useRef } from 'react' import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream' import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer' import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals' +import { burstVibeHearts } from '@/components/chat/vibe-hearts' import { translateNow } from '@/i18n' import { type GatewayEventPayload, textPart } from '@/lib/chat-messages' import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime' import { playCompletionSound } from '@/lib/completion-sound' -import { gatewayEventRequiresSessionId } from '@/lib/gateway-events' +import { resolveGatewayEventSessionId } from '@/lib/gateway-events' import { triggerHaptic } from '@/lib/haptics' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' +import { reconcileApprovalModeForProfile } from '@/store/approval-mode' import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' import { setSessionCompacting } from '@/store/compaction' import { refreshBackgroundProcesses } from '@/store/composer-status' @@ -19,6 +21,7 @@ import { dispatchNativeNotification } from '@/store/native-notifications' import { notify } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet' +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { followActiveSessionCwd } from '@/store/projects' import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts' import { @@ -92,16 +95,27 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { upsertToolCall } = deps + const unscopedStreamSessionIdRef = useRef<string | null>(null) + return useCallback( (event: RpcEvent) => { const payload = event.payload as GatewayEventPayload | undefined const explicitSid = event.session_id || '' - if (!explicitSid && gatewayEventRequiresSessionId(event.type)) { + const route = resolveGatewayEventSessionId({ + activeSessionId: activeSessionIdRef.current, + eventType: event.type, + explicitSessionId: explicitSid, + unscopedStreamSessionId: unscopedStreamSessionIdRef.current + }) + + unscopedStreamSessionIdRef.current = route.nextUnscopedStreamSessionId + + if (route.drop) { return } - const sessionId = explicitSid || activeSessionIdRef.current + const sessionId = route.sessionId const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current if (event.type === 'gateway.ready') { @@ -116,6 +130,20 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { const providerChanged = typeof payload?.provider === 'string' const runningChanged = typeof payload?.running === 'boolean' + // Config is profile-scoped, but session.info also arrives for background + // sessions. Only an active-session event from the currently active + // gateway may reconcile the foreground cache. Requiring the renderer's + // source tag prevents an event queued before a profile swap from being + // attributed to the newly active profile. + if ( + isActiveEvent && + typeof payload?.approval_mode === 'string' && + event.profile && + normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get()) + ) { + reconcileApprovalModeForProfile(event.profile, payload.approval_mode) + } + if (apply) { if (modelChanged) { setCurrentModel(payload!.model || '') @@ -264,6 +292,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // KawaiiSpinner), not real reasoning. The bottom-of-thread loading // indicator already covers that UX, so we ignore these events to // avoid a duplicative "Thinking" disclosure showing spinner text. + } else if (event.type === 'reaction') { + // Core-detected affection (ily / <3 / good bot) on the user's message. + // Play hearts only for the visible session so background turns stay quiet. + if (isActiveEvent && (payload?.kind ?? 'vibe') === 'vibe') { + burstVibeHearts() + } } else if (event.type === 'reasoning.delta') { if (sessionId) { appendReasoningDelta(sessionId, coerceThinkingText(payload?.text)) @@ -466,9 +500,11 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { setApprovalRequest({ // false only when a tirith warning forbids it; backend omits the field otherwise. allowPermanent: payload?.allow_permanent !== false, + choices: Array.isArray(payload?.choices) ? payload.choices.filter(choice => typeof choice === 'string') : undefined, command, description, - sessionId: sessionId ?? null + sessionId: sessionId ?? null, + smartDenied: payload?.smart_denied === true }) if (sessionId) { diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index 92888b4780d..9b7e08cf0dd 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -109,7 +109,7 @@ describe('useModelControls', () => { expect($currentProvider.get()).toBe('deepseek') }) - it('routes active-session picker changes through config.set with an explicit provider', async () => { + it('routes active-session picker changes through config.set with an explicit session-scoped provider', async () => { const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never) let controls!: Controls @@ -127,11 +127,33 @@ describe('useModelControls', () => { expect(requestGateway).toHaveBeenCalledWith('config.set', { session_id: 'session-1', key: 'model', - value: 'claude-sonnet-4.6 --provider anthropic' + value: 'claude-sonnet-4.6 --provider anthropic --session' }) expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) }) + it('session-scopes MoA preset selections so they cannot persist as the global gateway default', async () => { + const requestGateway = vi.fn(async () => ({ key: 'model', value: 'BeastMode' }) as never) + let controls!: Controls + + render( + <Harness activeSessionId="session-1" onReady={value => (controls = value)} requestGateway={requestGateway} /> + ) + + await expect( + controls.selectModel({ + model: 'BeastMode', + provider: 'moa' + }) + ).resolves.toBe(true) + + expect(requestGateway).toHaveBeenCalledWith('config.set', { + session_id: 'session-1', + key: 'model', + value: 'BeastMode --provider moa --session' + }) + }) + it('stores a no-session pick as UI state with no gateway or global write', async () => { const requestGateway = vi.fn() let controls!: Controls diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index dba30dd8d14..b302a5fa455 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -96,7 +96,7 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway await requestGateway('config.set', { session_id: activeSessionId, key: 'model', - value: `${selection.model} --provider ${selection.provider}` + value: `${selection.model} --provider ${selection.provider} --session` }) void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] }) diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx index 119bb51a040..3af7455a953 100644 --- a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx @@ -62,9 +62,9 @@ describe('usePreviewRouting', () => { $currentCwd.set('/work') $messages.set([]) $previewTarget.set(null) - window.localStorage.clear() clearSessionPreviewRegistry() handleEvent = () => undefined + window.localStorage.clear() Object.defineProperty(window, 'hermesDesktop', { configurable: true, @@ -78,9 +78,9 @@ describe('usePreviewRouting', () => { cleanup() $messages.set([]) $previewTarget.set(null) - window.localStorage.clear() - clearSessionPreviewRegistry() vi.restoreAllMocks() + clearSessionPreviewRegistry() + window.localStorage.clear() }) it('opens the active session preview from the registry', async () => { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 5ce2ad8aa56..3e725b1480d 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, waitFor } from '@testing-library/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' import type { MutableRefObject } from 'react' import { useEffect, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -43,6 +43,18 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo { } } +// Wrap render() in act() so the Harness's useEffect (onReady callback + +// internal state from usePromptActions) flushes synchronously instead of +// spilling async state updates outside act(). +async function actRender(ui: React.ReactElement) { + let result: ReturnType<typeof render> + await act(async () => { + result = render(ui) + }) + + return result! +} + interface HarnessHandle { cancelRun: () => Promise<void> restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void> @@ -51,7 +63,9 @@ interface HarnessHandle { } function Harness({ + activeSessionIdRef: activeSessionIdRefProp, busyRef, + getRouteToken, onReady, onSeedState, openMemoryGraph, @@ -59,11 +73,14 @@ function Harness({ requestGateway, resumeStoredSession, seedMessages, + selectedStoredSessionIdRef: selectedStoredSessionIdRefProp, storedSessionId, activeSessionId, createBackendSessionForSend }: { + activeSessionIdRef?: MutableRefObject<string | null> busyRef?: MutableRefObject<boolean> + getRouteToken?: () => string onReady: (handle: HarnessHandle) => void onSeedState?: (state: Record<string, unknown>) => void openMemoryGraph?: () => void @@ -71,15 +88,16 @@ function Harness({ requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> resumeStoredSession?: (storedSessionId: string) => Promise<void> | void seedMessages?: unknown[] + selectedStoredSessionIdRef?: MutableRefObject<string | null> storedSessionId?: null | string activeSessionId?: null | string createBackendSessionForSend?: () => Promise<null | string> }) { - const activeSessionIdRef: MutableRefObject<string | null> = { + const activeSessionIdRef: MutableRefObject<string | null> = activeSessionIdRefProp ?? { current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId } - const selectedStoredSessionIdRef: MutableRefObject<string | null> = { + const selectedStoredSessionIdRef: MutableRefObject<string | null> = selectedStoredSessionIdRefProp ?? { current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId } @@ -98,6 +116,7 @@ function Harness({ branchCurrentSession: async () => true, busyRef: localBusyRef, createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID), + getRouteToken: getRouteToken ?? (() => 'token'), handleSkinCommand: () => '', openMemoryGraph: openMemoryGraph ?? (() => undefined), refreshSessions, @@ -118,10 +137,14 @@ function Harness({ useEffect(() => { onReady({ - cancelRun: actions.cancelRun, - restoreToMessage: actions.restoreToMessage, - steerPrompt: actions.steerPrompt, - submitText: actions.submitText + cancelRun: (...args: Parameters<typeof actions.cancelRun>) => + act(async () => actions.cancelRun(...args)) as Promise<void>, + restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) => + act(async () => actions.restoreToMessage(...args)) as Promise<void>, + steerPrompt: (...args: Parameters<typeof actions.steerPrompt>) => + act(async () => actions.steerPrompt(...args)) as Promise<boolean>, + submitText: (...args: Parameters<typeof actions.submitText>) => + act(async () => actions.submitText(...args)) as Promise<boolean> }) }, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady]) @@ -146,7 +169,9 @@ describe('usePromptActions /title', () => { ) let handle: HarnessHandle | null = null - render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + await actRender( + <Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} /> + ) await handle!.submitText('/title New title') @@ -170,7 +195,9 @@ describe('usePromptActions /title', () => { ) let handle: HarnessHandle | null = null - render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + await actRender( + <Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} /> + ) await handle!.submitText('/title Fresh chat') @@ -188,7 +215,9 @@ describe('usePromptActions /title', () => { const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never) let handle: HarnessHandle | null = null - render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + await actRender( + <Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} /> + ) await handle!.submitText('/title') @@ -208,7 +237,9 @@ describe('usePromptActions /title', () => { }) let handle: HarnessHandle | null = null - render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + await actRender( + <Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} /> + ) await handle!.submitText('/title way too long title') @@ -247,7 +278,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={s => states.push(s)} @@ -297,7 +328,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={s => states.push(s)} @@ -335,7 +366,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -365,7 +396,7 @@ describe('usePromptActions desktop slash pickers', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -385,7 +416,7 @@ describe('usePromptActions desktop slash pickers', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} openMemoryGraph={openMemoryGraph} @@ -418,7 +449,7 @@ describe('usePromptActions desktop slash pickers', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -448,7 +479,7 @@ describe('usePromptActions submit / queue drain semantics', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={s => seeds.push(s)} @@ -481,7 +512,7 @@ describe('usePromptActions submit / queue drain semantics', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness busyRef={busyRef} onReady={h => (handle = h)} @@ -523,7 +554,7 @@ describe('usePromptActions submit / queue drain semantics', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -567,7 +598,7 @@ describe('usePromptActions submit / queue drain semantics', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={s => seeds.push(s)} @@ -589,7 +620,7 @@ describe('usePromptActions submit / queue drain semantics', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness busyRef={busyRef} onReady={h => (handle = h)} @@ -615,7 +646,7 @@ describe('usePromptActions steerPrompt', () => { const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -634,7 +665,7 @@ describe('usePromptActions steerPrompt', () => { const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -647,7 +678,7 @@ describe('usePromptActions steerPrompt', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -658,7 +689,7 @@ describe('usePromptActions steerPrompt', () => { const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -690,7 +721,7 @@ describe('usePromptActions restoreToMessage', () => { let lastState: Record<string, unknown> = {} let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={state => (lastState = state)} @@ -725,7 +756,7 @@ describe('usePromptActions restoreToMessage', () => { let lastState: Record<string, unknown> = {} let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={state => (lastState = state)} @@ -758,7 +789,7 @@ describe('usePromptActions restoreToMessage', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -786,7 +817,7 @@ describe('usePromptActions restoreToMessage', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -801,7 +832,7 @@ describe('usePromptActions restoreToMessage', () => { let lastState: Record<string, unknown> = {} let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={state => (lastState = state)} @@ -875,7 +906,7 @@ describe('usePromptActions file attachment sync', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -929,7 +960,7 @@ describe('usePromptActions file attachment sync', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -958,7 +989,7 @@ describe('usePromptActions file attachment sync', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -1018,7 +1049,7 @@ describe('usePromptActions eager-upload races', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) await waitFor(() => expect(handle).not.toBeNull()) @@ -1076,7 +1107,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -1119,7 +1150,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -1152,7 +1183,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={s => states.push(s)} @@ -1182,7 +1213,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -1226,7 +1257,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -1239,7 +1270,7 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(ok).toBe(true) expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message during starved loop' @@ -1266,7 +1297,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness activeSessionId={null} createBackendSessionForSend={createBackendSessionForSend} @@ -1287,7 +1318,18 @@ describe('usePromptActions sleep/wake session recovery', () => { }) it('still creates a new session for a genuine new-chat draft (no stored session selected)', async () => { - const createBackendSessionForSend = vi.fn(async () => RUNTIME_SESSION_ID) + const activeSessionIdRef: MutableRefObject<string | null> = { current: null } + + // Mirror the real createBackendSessionForSend: a successful create + // re-homes the active runtime ref to the session it minted BEFORE + // returning. An inert stub here is what let the new-chat drift-abort + // regression ship green. + const createBackendSessionForSend = vi.fn(async () => { + activeSessionIdRef.current = RUNTIME_SESSION_ID + + return RUNTIME_SESSION_ID + }) + const calls: string[] = [] const requestGateway = vi.fn(async (method: string) => { @@ -1297,9 +1339,10 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness activeSessionId={null} + activeSessionIdRef={activeSessionIdRef} createBackendSessionForSend={createBackendSessionForSend} onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -1316,6 +1359,226 @@ describe('usePromptActions sleep/wake session recovery', () => { }) }) +describe('usePromptActions submit session-context isolation (#54527)', () => { + const STORED_SESSION_A = 'stored-project-a' + const STORED_SESSION_B = 'stored-project-b' + const RUNTIME_SESSION_B = 'rt-session-b-wrong' + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('aborts submit when the user switches sessions during session.resume (no misroute)', async () => { + // Exact #54527 failure: user submits in Session A while its runtime binding + // is gone; before resume returns they switch to Session B. Without a pinned + // context the resumed runtime id belongs to B and A's text lands in the + // wrong chat — permanently lost from A. + let releaseResume: () => void = () => {} + const calls: { method: string; params?: Record<string, unknown> }[] = [] + + const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A } + const activeSessionIdRef: MutableRefObject<string | null> = { current: null } + + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { + calls.push({ method, params }) + + if (method === 'session.resume') { + await new Promise<void>(resolve => { + releaseResume = resolve + }) + + // Simulate the user switching to Session B while resume is in flight. + selectedStoredSessionIdRef.current = STORED_SESSION_B + activeSessionIdRef.current = RUNTIME_SESSION_B + + return { session_id: RUNTIME_SESSION_B } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + <Harness + activeSessionId={null} + activeSessionIdRef={activeSessionIdRef} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={STORED_SESSION_A} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + const submitting = handle!.submitText('carefully composed prompt for project A') + await waitFor(() => expect(calls.some(c => c.method === 'session.resume')).toBe(true)) + releaseResume() + + expect(await submitting).toBe(false) + expect(calls.some(c => c.method === 'prompt.submit')).toBe(false) + expect(calls.find(c => c.method === 'session.resume')?.params).toEqual({ + session_id: STORED_SESSION_A + }) + }) + + it('aborts recovery submit when the user switches sessions during timeout resume', async () => { + const calls: { method: string; params?: Record<string, unknown> }[] = [] + let submitAttempts = 0 + let releaseResume: () => void = () => {} + + const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A } + + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { + calls.push({ method, params }) + + if (method === 'prompt.submit') { + submitAttempts += 1 + + if (submitAttempts === 1) { + throw new Error('request timed out: prompt.submit') + } + } + + if (method === 'session.resume') { + await new Promise<void>(resolve => { + releaseResume = resolve + }) + selectedStoredSessionIdRef.current = STORED_SESSION_B + + return { session_id: RUNTIME_SESSION_B } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + <Harness + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={STORED_SESSION_A} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + const submitting = handle!.submitText('message that must not land in session B') + await waitFor(() => expect(calls.some(c => c.method === 'session.resume')).toBe(true)) + releaseResume() + + expect(await submitting).toBe(false) + expect(submitAttempts).toBe(1) + expect(calls.filter(c => c.method === 'prompt.submit')).toHaveLength(1) + expect(calls.find(c => c.method === 'session.resume')?.params).toMatchObject({ + session_id: STORED_SESSION_A + }) + }) + + it('submits the first prompt of a new chat — the create pipeline re-homing selection/route is not user drift', async () => { + // Regression for the #54527 guard breaking every NEW chat: on a fresh draft + // (no stored session, no runtime session) createBackendSessionForSend + // legitimately sets selectedStoredSessionIdRef + navigates to the new + // session's route. Comparing against the pre-create (null) baseline made + // the guard read that self-inflicted move as a user switch and abort, so + // prompt.submit never fired: the message vanished, no DB row was ever + // persisted, and the desktop stranded on a route whose REST reads 404 + // ("Session not found"). + const calls: { method: string; params?: Record<string, unknown> }[] = [] + const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null } + const activeSessionIdRef: MutableRefObject<string | null> = { current: null } + let routeToken = '/' + + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { + calls.push({ method, params }) + + return {} as never + }) + + // Mirror the real createBackendSessionForSend: on success it re-homes the + // refs AND the route to the session it just created. + const createBackendSessionForSend = vi.fn(async () => { + activeSessionIdRef.current = 'rt-new-chat' + selectedStoredSessionIdRef.current = 'stored-new-chat' + routeToken = '/stored-new-chat' + + return 'rt-new-chat' + }) + + let handle: HarnessHandle | null = null + render( + <Harness + activeSessionId={null} + activeSessionIdRef={activeSessionIdRef} + createBackendSessionForSend={createBackendSessionForSend} + getRouteToken={() => routeToken} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={null} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + expect(await handle!.submitText('first message of a brand-new chat')).toBe(true) + expect(createBackendSessionForSend).toHaveBeenCalledTimes(1) + expect(calls.find(c => c.method === 'prompt.submit')?.params).toMatchObject({ + session_id: 'rt-new-chat' + }) + }) + + it('aborts when the user switches sessions during the tail of a successful create', async () => { + // createBackendSessionForSend awaits once more (armed-YOLO apply) AFTER + // committing the refs and returning a real id, so a switch in that window + // escapes its internal null-return drift check. The active ref is the + // tell: every switch path retargets it synchronously, so it no longer + // equals the id create returned. The submit must abort, not adopt the + // switched-to context as its re-pinned baseline. + const calls: { method: string; params?: Record<string, unknown> }[] = [] + const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null } + const activeSessionIdRef: MutableRefObject<string | null> = { current: null } + let routeToken = '/' + + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { + calls.push({ method, params }) + + return {} as never + }) + + const createBackendSessionForSend = vi.fn(async () => { + // The user switched to Session B during the post-commit await: the + // switch path re-homed all three context markers before create returned. + activeSessionIdRef.current = RUNTIME_SESSION_B + selectedStoredSessionIdRef.current = STORED_SESSION_B + routeToken = `/${STORED_SESSION_B}` + + return 'rt-new-chat' + }) + + let handle: HarnessHandle | null = null + render( + <Harness + activeSessionId={null} + activeSessionIdRef={activeSessionIdRef} + createBackendSessionForSend={createBackendSessionForSend} + getRouteToken={() => routeToken} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={null} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + expect(await handle!.submitText('message that must not land in session B')).toBe(false) + expect(calls.some(c => c.method === 'prompt.submit')).toBe(false) + }) +}) + describe('usePromptActions eager attachment upload (drop-time)', () => { afterEach(() => { cleanup() @@ -1353,7 +1616,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => { { id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' } ]) - render( + await actRender( <Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -1383,7 +1646,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => { $composerAttachments.set([{ id: 'file:x', kind: 'file', label: 'x.pdf', path: '/abs/x.pdf' }]) - render( + await actRender( <Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -1407,7 +1670,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => { } ]) - render( + await actRender( <Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -1422,7 +1685,7 @@ describe('uploadComposerAttachment remote read failures', () => { }) it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => { - // electron/hardening.cjs rejects the readFileDataUrl IPC with this exact + // electron/hardening.ts rejects the readFileDataUrl IPC with this exact // shape when a file exceeds DATA_URL_READ_MAX_BYTES. Object.defineProperty(window, 'hermesDesktop', { configurable: true, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 8ebe8889d16..51a3689ae4e 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -158,6 +158,7 @@ interface PromptActionsOptions { busyRef: MutableRefObject<boolean> branchCurrentSession: () => Promise<boolean> createBackendSessionForSend: (preview?: string | null) => Promise<string | null> + getRouteToken: () => string handleSkinCommand: (arg: string) => string openMemoryGraph: () => void refreshSessions: () => Promise<void> @@ -186,6 +187,7 @@ export function usePromptActions({ busyRef, branchCurrentSession, createBackendSessionForSend, + getRouteToken, handleSkinCommand, openMemoryGraph, refreshSessions, @@ -354,6 +356,7 @@ export function usePromptActions({ busyRef, copy, createBackendSessionForSend, + getRouteToken, requestGateway, selectedStoredSessionIdRef, syncAttachmentsForSubmit, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 76a25885854..b1eaa4966bf 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -21,9 +21,9 @@ import { _submitInFlight, type GatewayRequest, inlineErrorMessage, + isGatewayTimeoutError, isProviderSetupError, isSessionBusyError, - isGatewayTimeoutError, isSessionNotFoundError, type SubmitTextOptions, withSessionBusyRetry @@ -35,6 +35,7 @@ interface SubmitPromptDeps { busyRef: MutableRefObject<boolean> copy: Translations['desktop'] createBackendSessionForSend: (preview?: string | null) => Promise<string | null> + getRouteToken: () => string requestGateway: GatewayRequest selectedStoredSessionIdRef: MutableRefObject<string | null> syncAttachmentsForSubmit: ( @@ -57,6 +58,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { busyRef, copy, createBackendSessionForSend, + getRouteToken, requestGateway, selectedStoredSessionIdRef, syncAttachmentsForSubmit, @@ -113,9 +115,22 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { return false } + // Pin the session context for the whole async submit pipeline. Without + // this, a fast session switch during session.resume / file.attach can + // redirect the user's text into a different chat (#54527). Mutable — + // not const — because a new-chat submit legitimately re-homes to the + // session it creates (see the re-pin after createBackendSessionForSend). + const startingActiveSessionId = activeSessionIdRef.current + let startingStoredSessionId = selectedStoredSessionIdRef.current + let startingRouteToken = getRouteToken() + + const sessionContextDrifted = (): boolean => + selectedStoredSessionIdRef.current !== startingStoredSessionId || + getRouteToken() !== startingRouteToken + // One submit in flight per session — drop any concurrent re-fire so a // stalled turn can't stack the same prompt into multiple real turns. - const submitLockKey = selectedStoredSessionIdRef.current || activeSessionId || '__pending_new__' + const submitLockKey = startingStoredSessionId || startingActiveSessionId || '__pending_new__' if (_submitInFlight.has(submitLockKey)) { return false @@ -166,7 +181,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // (what made drained-after-interrupt sends go silent). interrupted: false }), - selectedStoredSessionIdRef.current + startingStoredSessionId ) // After sync rewrites refs, refresh the optimistic message in place so the @@ -178,7 +193,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { ...state, messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message)) }), - selectedStoredSessionIdRef.current + startingStoredSessionId ) const dropOptimistic = (sid: null | string) => { @@ -197,10 +212,17 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { awaitingResponse: false, pendingBranchGroup: null }), - selectedStoredSessionIdRef.current + startingStoredSessionId ) } + const abortForSessionSwitch = (optimisticSessionId: null | string): false => { + dropOptimistic(optimisticSessionId) + releaseBusy() + + return false + } + setMutableRef(busyRef, true) setBusy(true) setAwaitingResponse(true) @@ -214,7 +236,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { setMessages(current => [...current, buildUserMessage()]) } - if (!sessionId && selectedStoredSessionIdRef.current) { + if (!sessionId && startingStoredSessionId) { // A stored session is SELECTED but its runtime binding is gone (the // live session was orphan-reaped, or a timeout/reconnect cleared // activeSessionId). Continuing the selected conversation must mean @@ -224,9 +246,13 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // new-chat draft). try { const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: selectedStoredSessionIdRef.current + session_id: startingStoredSessionId }) + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + if (resumed?.session_id) { sessionId = resumed.session_id activeSessionIdRef.current = sessionId @@ -237,6 +263,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // the user's message. } + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + if (sessionId) { seedOptimistic(sessionId) } @@ -254,6 +284,13 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } if (!sessionId) { + // createBackendSessionForSend returns null when the user switched + // sessions mid-create (it closes the orphaned session itself) — + // abort silently. Anything else is a real failure worth a toast. + if (sessionContextDrifted()) { + return abortForSessionSwitch(null) + } + dropOptimistic(null) releaseBusy() notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) @@ -261,6 +298,23 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { return false } + // A successful create re-homes selection + route to the chat it just + // minted, so the pre-create baseline can't tell our own re-home from + // a user switch (judging it drift aborted EVERY first send of a new + // chat: no prompt.submit, no DB row, a stranded route that 404s + // "Session not found"). The drift signal for this window is the + // active ref instead: every switch path re-nulls or retargets it + // synchronously, so it only still equals the id create returned when + // nobody re-homed since. + if (activeSessionIdRef.current !== sessionId) { + return abortForSessionSwitch(sessionId) + } + + // Re-pin the baseline to the created chat for the rest of the + // pipeline; the closures (seedOptimistic et al) see the new value. + startingStoredSessionId = selectedStoredSessionIdRef.current + startingRouteToken = getRouteToken() + seedOptimistic(sessionId) } @@ -269,6 +323,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { updateComposerAttachments: usingComposerAttachments }) + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + // Rewrite the optimistic message + prompt text with the synced refs so // the gateway receives @file: paths that resolve in its workspace. // (Images keep their inline base64 preview — see optimisticAttachmentRef.) @@ -288,7 +346,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } catch (firstErr) { if ( (isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && - selectedStoredSessionIdRef.current + startingStoredSessionId ) { // Re-register the session in the gateway and get a fresh live ID. // Timeouts recover the same way as "session not found": a starved @@ -296,10 +354,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // the stored session is fine — resume + retry instead of erroring // out and losing the session binding. const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: selectedStoredSessionIdRef.current, + session_id: startingStoredSessionId, source: 'desktop' }) + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + const recoveredId = resumed?.session_id if (recoveredId) { @@ -375,6 +437,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { busyRef, copy, createBackendSessionForSend, + getRouteToken, requestGateway, selectedStoredSessionIdRef, syncAttachmentsForSubmit, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index 1df123824d1..de501a52bbc 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -142,7 +142,7 @@ export async function readFileDataUrlForAttach(filePath: string): Promise<string } // The readFileDataUrl IPC base64-loads the whole file into memory and is -// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which +// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.ts, which // rejects with a raw "file is too large (N bytes; limit M bytes)" string. In // remote mode every attachment's bytes go through that read, so a big file // surfaces that internal message verbatim in the failure toast. Translate it diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index e00960de2f2..45bab0c79c1 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, waitFor } from '@testing-library/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' import type { MutableRefObject } from 'react' import { useEffect } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -6,13 +6,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getSessionMessages, type SessionInfo } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' import { $activeGatewayProfile, $newChatProfile } from '@/store/profile' +import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' import { $activeSessionId, $currentCwd, $messages, + $newChatWorkspaceTarget, $resumeFailedSessionId, setActiveSessionId, + setCurrentCwd, setMessages, + setNewChatWorkspaceTarget, setResumeFailedSessionId, setSessions } from '@/store/session' @@ -31,6 +35,7 @@ vi.mock('@/hermes', async importOriginal => ({ })) const RUNTIME_SESSION_ID = 'rt-new-001' +type HarnessHandle = Pick<ReturnType<typeof useSessionActions>, 'createBackendSessionForSend' | 'startFreshSessionDraft'> function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo { return { @@ -55,7 +60,7 @@ function Harness({ onReady, requestGateway }: { - onReady: (create: (preview?: string | null) => Promise<string | null>) => void + onReady: (handle: HarnessHandle) => void requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> }) { const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value }) @@ -69,6 +74,7 @@ function Harness({ getRouteToken: () => 'token', navigate: vi.fn() as never, requestGateway, + resetViewSync: vi.fn(), runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()), selectedStoredSessionId: null, selectedStoredSessionIdRef: ref<string | null>(null), @@ -78,13 +84,16 @@ function Harness({ }) useEffect(() => { - onReady(actions.createBackendSessionForSend) - }, [actions.createBackendSessionForSend, onReady]) + onReady(actions) + }, [actions, onReady]) return null } -async function createWith(profileSetup: () => void): Promise<Record<string, unknown> | undefined> { +async function createWith( + profileSetup: () => void, + beforeCreate?: (handle: HarnessHandle) => Promise<void> | void +): Promise<Record<string, unknown> | undefined> { let createParams: Record<string, unknown> | undefined const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { @@ -97,13 +106,23 @@ async function createWith(profileSetup: () => void): Promise<Record<string, unkn return {} as never }) - $currentCwd.set('') + setCurrentCwd('') + setNewChatWorkspaceTarget(undefined) profileSetup() - let create: ((preview?: string | null) => Promise<string | null>) | null = null - render(<Harness onReady={c => (create = c)} requestGateway={requestGateway} />) - await waitFor(() => expect(create).not.toBeNull()) - await create!() + let handle: HarnessHandle | null = null + render(<Harness onReady={h => (handle = h)} requestGateway={requestGateway} />) + await waitFor(() => expect(handle).not.toBeNull()) + + if (beforeCreate) { + await act(async () => { + await beforeCreate(handle!) + }) + } + + await act(async () => { + await handle!.createBackendSessionForSend() + }) return createParams } @@ -113,7 +132,10 @@ describe('createBackendSessionForSend profile routing', () => { cleanup() $newChatProfile.set(null) $activeGatewayProfile.set('default') + $projectScope.set(ALL_PROJECTS) + $projectTree.set([]) $currentCwd.set('') + setNewChatWorkspaceTarget(undefined) vi.restoreAllMocks() }) @@ -161,6 +183,24 @@ describe('createBackendSessionForSend profile routing', () => { expect(params).toMatchObject({ cwd: '/remote/worktree' }) }) + + it('falls back to the entered project cwd when the current cwd is blank', async () => { + const params = await createWith(() => { + $projectTree.set([ + { + id: 'p_app', + label: 'App', + path: '/repo/app', + repos: [{ groups: [], id: '/repo/app', label: 'app', path: '/repo/app', sessionCount: 0 }], + sessionCount: 0 + } + ]) + $projectScope.set('p_app') + $currentCwd.set('') + }) + + expect(params).toMatchObject({ cwd: '/repo/app' }) + }) }) // ── Resume failure recovery (the "stuck loading session window" bug) ────────── @@ -190,6 +230,7 @@ function ResumeHarness({ getRouteToken: () => 'token', navigate: vi.fn() as never, requestGateway, + resetViewSync: vi.fn(), runtimeIdByStoredSessionIdRef: runtimeIdByStoredSessionIdRef ?? ref(new Map<string, string>()), selectedStoredSessionId: null, selectedStoredSessionIdRef: ref<string | null>(null), @@ -437,6 +478,7 @@ function BranchHarness({ getRouteToken: () => 'token', navigate: vi.fn() as never, requestGateway, + resetViewSync: vi.fn(), runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()), selectedStoredSessionId: null, selectedStoredSessionIdRef: ref<string | null>(null), @@ -592,3 +634,44 @@ describe('resumeSession warm-cache mapping integrity', () => { expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A') }) }) + +describe('createBackendSessionForSend workspace target', () => { + afterEach(() => { + cleanup() + $newChatProfile.set(null) + $activeGatewayProfile.set('default') + setCurrentCwd('') + setNewChatWorkspaceTarget(undefined) + vi.restoreAllMocks() + }) + + it('omits cwd for an explicit no-workspace draft even when global cwd changes before send', async () => { + const params = await createWith( + () => { + $activeGatewayProfile.set('default') + }, + handle => { + handle.startFreshSessionDraft({ workspaceTarget: null }) + $currentCwd.set('/project-open-in-file-browser') + } + ) + + expect(params).not.toHaveProperty('cwd') + expect($newChatWorkspaceTarget.get()).toBeUndefined() + }) + + it('uses the clicked workspace target instead of a later global cwd value', async () => { + const params = await createWith( + () => { + $activeGatewayProfile.set('default') + }, + handle => { + handle.startFreshSessionDraft({ workspaceTarget: '/clicked-workspace' }) + $currentCwd.set('/project-open-in-file-browser') + } + ) + + expect(params).toMatchObject({ cwd: '/clicked-workspace' }) + }) + +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 2b51f002e4b..831fba0fe25 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -18,19 +18,23 @@ import { $currentProvider, $currentReasoningEffort, $messages, + $newChatWorkspaceTarget, $sessions, $yoloActive, + type NewChatWorkspaceTarget, sessionPinId, setActiveSessionId, setAwaitingResponse, setBusy, setCurrentBranch, setCurrentCwd, + setCurrentCwdTransient, setCurrentServiceTier, setCurrentUsage, setFreshDraftReady, setIntroSeed, setMessages, + setNewChatWorkspaceTarget, setResumeExhaustedSessionId, setResumeFailedSessionId, setSelectedStoredSessionId, @@ -38,8 +42,7 @@ import { setSessionStartedAt, setSessionsTotal, setTurnStartedAt, - setYoloActive, - workspaceCwdForNewSession + setYoloActive } from '@/store/session' import { broadcastSessionsChanged } from '@/store/session-sync' import { isWatchWindow } from '@/store/windows' @@ -72,6 +75,7 @@ interface SessionActionsOptions { getRouteToken: () => string navigate: NavigateFunction requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> + resetViewSync: () => void runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> selectedStoredSessionId: string | null selectedStoredSessionIdRef: MutableRefObject<string | null> @@ -84,6 +88,15 @@ interface SessionActionsOptions { ) => ClientSessionState } +interface FreshSessionDraftOptions { + replaceRoute?: boolean + workspaceTarget?: NewChatWorkspaceTarget +} + +function normalizeNewChatWorkspaceTarget(target: NewChatWorkspaceTarget): NewChatWorkspaceTarget { + return typeof target === 'string' ? target.trim() || null : target +} + export function useSessionActions({ activeSessionId, activeSessionIdRef, @@ -93,6 +106,7 @@ export function useSessionActions({ getRouteToken, navigate, requestGateway, + resetViewSync, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, selectedStoredSessionIdRef, @@ -105,7 +119,18 @@ export function useSessionActions({ const resumeRequestRef = useRef(0) const startFreshSessionDraft = useCallback( - (replaceRoute = false) => { + (options: boolean | FreshSessionDraftOptions = false) => { + const draftOptions = typeof options === 'boolean' ? { replaceRoute: options } : options + const replaceRoute = draftOptions.replaceRoute ?? false + + const hasWorkspaceTarget = + Object.hasOwn(draftOptions, 'workspaceTarget') && draftOptions.workspaceTarget !== undefined + + const workspaceTarget = hasWorkspaceTarget + ? normalizeNewChatWorkspaceTarget(draftOptions.workspaceTarget) + : undefined + + resetViewSync() busyRef.current = false setBusy(false) setAwaitingResponse(false) @@ -133,15 +158,23 @@ export function useSessionActions({ // is cleared. setCurrentServiceTier('') setYoloActive(false) - // In a project → the repo's default-branch (main worktree) checkout; not in - // a project → detached. So cmd-n "knows" the project instead of inheriting - // whatever linked worktree the last session drifted into. - setCurrentCwd(resolveNewSessionCwd()) + setNewChatWorkspaceTarget(hasWorkspaceTarget ? workspaceTarget : undefined) + + if (!hasWorkspaceTarget) { + // In a project → the repo's default-branch checkout; not in a project → + // detached. So cmd-n does not inherit an unrelated linked worktree. + setCurrentCwd(resolveNewSessionCwd()) + } else if (workspaceTarget === null) { + setCurrentCwdTransient('') + } else if (typeof workspaceTarget === 'string') { + setCurrentCwd(workspaceTarget) + } + setCurrentBranch('') // Never clear the composer here — ChatBar's per-thread draft swap owns it. setFreshDraftReady(true) }, - [activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef] + [activeSessionIdRef, busyRef, navigate, resetViewSync, selectedStoredSessionIdRef] ) const createBackendSessionForSend = useCallback( @@ -163,7 +196,18 @@ export function useSessionActions({ // a backend resolves its own launch profile to None (_profile_home). const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get()) await ensureGatewayProfile(newChatProfile) - const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession() + // An explicit one-shot workspace target (null → detached, string → that + // folder) wins; otherwise fall through to the live cwd, then the + // project-aware default (resolveNewSessionCwd). + const workspaceTarget = $newChatWorkspaceTarget.get() + + const cwd = + workspaceTarget === null + ? '' + : typeof workspaceTarget === 'string' + ? workspaceTarget.trim() + : $currentCwd.get().trim() || resolveNewSessionCwd() + // The composer's model/effort/fast is sticky UI state ($currentModel, // $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it // with every session.create so the new chat opens on whatever the picker @@ -196,6 +240,7 @@ export function useSessionActions({ return null } + resetViewSync() activeSessionIdRef.current = created.session_id selectedStoredSessionIdRef.current = stored ensureSessionState(created.session_id, stored) @@ -213,6 +258,7 @@ export function useSessionActions({ } setFreshDraftReady(false) + setNewChatWorkspaceTarget(undefined) setActiveSessionId(created.session_id) setSelectedStoredSessionId(stored) setSessionStartedAt(Date.now()) @@ -243,6 +289,7 @@ export function useSessionActions({ getRouteToken, navigate, requestGateway, + resetViewSync, selectedStoredSessionIdRef, updateSessionState ] @@ -295,6 +342,7 @@ export function useSessionActions({ // resume entry"). setFreshDraftReady(false) clearNotifications() + resetViewSync() setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId // Optimistically clear any prior resume-failure latch for this session: @@ -625,6 +673,7 @@ export function useSessionActions({ busyRef, copy, requestGateway, + resetViewSync, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index 680cc754286..7ddc6667968 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import type { ChatMessage } from '@/lib/chat-messages' +import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode' +import { $activeGatewayProfile } from '@/store/profile' import type { SessionInfo } from '@/types/hermes' import { + applyRuntimeInfo, chatMessageArraysEquivalent, isSessionGoneError, reconcileResumeMessages, @@ -17,6 +20,20 @@ const msg = (id: string, role: ChatMessage['role'], text: string, extra: Partial const session = (over: Partial<SessionInfo>): SessionInfo => over as SessionInfo +describe('applyRuntimeInfo approval mode', () => { + beforeEach(() => { + $approvalModes.set({}) + $activeGatewayProfile.set('work') + }) + + it('reconciles session.info against the gateway profile', () => { + applyRuntimeInfo({ approval_mode: 'smart', desktop_contract: 3 }) + + expect(approvalModeForProfile('work')).toBe('smart') + expect(approvalModeForProfile('default')).toBe('smart') + }) +}) + describe('isSessionGoneError', () => { it('is true for 404 / session-not-found, false otherwise', () => { expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index d299fe51b7e..06458eb5902 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -2,6 +2,7 @@ import { getSession } from '@/hermes' import { type ChatMessage, chatMessageText } from '@/lib/chat-messages' import { normalizePersonalityValue } from '@/lib/chat-runtime' import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images' +import { reconcileApprovalModeForProfile } from '@/store/approval-mode' import { requestDesktopOnboarding } from '@/store/onboarding' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { @@ -266,6 +267,10 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR reportBackendContract(info.desktop_contract) + if (info.approval_mode !== undefined) { + reconcileApprovalModeForProfile($activeGatewayProfile.get(), info.approval_mode) + } + if (info.credential_warning) { requestDesktopOnboarding(info.credential_warning) } diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 3f8e02c8ca8..22ce8796f74 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -130,6 +130,18 @@ export function useSessionStateCache({ return created }, []) + const resetViewSync = useCallback(() => { + // Drop any RAF-pending transcript stage so a backgrounded turn cannot + // repaint over the chat the user just switched to (#47709 / #47743). + pendingViewStateRef.current = null + viewSessionIdRef.current = null + + if (viewSyncRafRef.current !== null && typeof window !== 'undefined') { + window.cancelAnimationFrame(viewSyncRafRef.current) + viewSyncRafRef.current = null + } + }, []) + const flushPendingViewState = useCallback(() => { const pending = pendingViewStateRef.current pendingViewStateRef.current = null @@ -306,6 +318,7 @@ export function useSessionStateCache({ return { activeSessionIdRef, ensureSessionState, + resetViewSync, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef, diff --git a/apps/desktop/src/app/session/workspace-session-target.test.ts b/apps/desktop/src/app/session/workspace-session-target.test.ts new file mode 100644 index 00000000000..3a83f605a32 --- /dev/null +++ b/apps/desktop/src/app/session/workspace-session-target.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + $currentBranch, + $currentCwd, + $newChatWorkspaceTarget, + setCurrentBranch, + setCurrentCwd, + setNewChatWorkspaceTarget +} from '@/store/session' + +import { startWorkspaceSession } from './workspace-session-target' + +function deferred<T>() { + let resolve!: (value: T) => void + + const promise = new Promise<T>(done => { + resolve = done + }) + + return { promise, resolve } +} + +describe('startWorkspaceSession', () => { + afterEach(() => { + setCurrentBranch('') + setCurrentCwd('') + setNewChatWorkspaceTarget(undefined) + vi.restoreAllMocks() + }) + + it('keeps a newer sidebar target when an older project lookup resolves', async () => { + const first = deferred<{ branch?: string; cwd?: string }>() + const second = deferred<{ branch?: string; cwd?: string }>() + + const requestGateway = vi + .fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + + const activeSessionIdRef = { current: null } + + const startFreshSessionDraft = vi.fn((options?: { workspaceTarget: string }) => { + setNewChatWorkspaceTarget(options?.workspaceTarget) + setCurrentCwd(options?.workspaceTarget || '') + }) + + const followActiveSessionCwd = vi.fn() + + startWorkspaceSession({ + activeSessionIdRef, + followActiveSessionCwd, + path: '/workspace-a', + requestGateway, + startFreshSessionDraft + }) + startWorkspaceSession({ + activeSessionIdRef, + followActiveSessionCwd, + path: '/workspace-b', + requestGateway, + startFreshSessionDraft + }) + + first.resolve({ branch: 'stale', cwd: '/normalized-a' }) + await first.promise + await Promise.resolve() + + expect($newChatWorkspaceTarget.get()).toBe('/workspace-b') + expect($currentCwd.get()).toBe('/workspace-b') + expect($currentBranch.get()).not.toBe('stale') + + second.resolve({ branch: 'main', cwd: '/normalized-b' }) + await second.promise + await Promise.resolve() + + expect($newChatWorkspaceTarget.get()).toBe('/normalized-b') + expect($currentCwd.get()).toBe('/normalized-b') + expect($currentBranch.get()).toBe('main') + }) +}) diff --git a/apps/desktop/src/app/session/workspace-session-target.ts b/apps/desktop/src/app/session/workspace-session-target.ts new file mode 100644 index 00000000000..da5028502a1 --- /dev/null +++ b/apps/desktop/src/app/session/workspace-session-target.ts @@ -0,0 +1,64 @@ +import type { MutableRefObject } from 'react' + +import { followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects' +import { + $newChatWorkspaceTargetGeneration, + setCurrentBranch, + setCurrentCwd, + setNewChatWorkspaceTarget +} from '@/store/session' + +interface WorkspaceSessionOptions { + activeSessionIdRef: MutableRefObject<string | null> + followActiveSessionCwd?: (cwd: string) => void | Promise<void> + onExplicitWorkspace?: (cwd: string) => void + path: null | string + requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> + startFreshSessionDraft: (options?: { workspaceTarget: string }) => void +} + +export function startWorkspaceSession({ + activeSessionIdRef, + followActiveSessionCwd: followCwd = followActiveSessionCwd, + onExplicitWorkspace, + path, + requestGateway, + startFreshSessionDraft +}: WorkspaceSessionOptions): void { + // A worktree lane carries its own path; a project trunk can be path-less, so + // fall back to the active project's root for that existing controller path. + const explicitTarget = path?.trim() + const target = explicitTarget || resolveNewSessionCwd() + + startFreshSessionDraft(target ? { workspaceTarget: target } : undefined) + + if (!target) { + return + } + + const workspaceGeneration = $newChatWorkspaceTargetGeneration.get() + + setCurrentCwd(target) + void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) + .then(info => { + if ($newChatWorkspaceTargetGeneration.get() !== workspaceGeneration || activeSessionIdRef.current) { + return + } + + const resolved = info.cwd || target + + setCurrentCwd(resolved) + setNewChatWorkspaceTarget(resolved) + setCurrentBranch(info.branch || '') + + if (explicitTarget) { + onExplicitWorkspace?.(resolved) + void followCwd(resolved) + } + }) + .catch(() => { + if ($newChatWorkspaceTargetGeneration.get() === workspaceGeneration && !activeSessionIdRef.current) { + setCurrentBranch('') + } + }) +} diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 6a87590b3cb..fa9e00eb349 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -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"> diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 5ae4eb393f5..8332ff82055 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -237,7 +237,7 @@ export const ENUM_OPTIONS: Record<string, string[]> = { 'approvals.mode': ['manual', 'smart', 'off'], 'code_execution.mode': ['project', 'strict'], 'context.engine': ['compressor', 'default', 'custom'], - 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'], + 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], 'memory.provider': ['', 'builtin', 'hindsight', 'honcho'], // Terminal execution backends — kept in sync with the dispatch ladder in // tools/terminal_tool.py::_create_environment (local/docker/singularity/ diff --git a/apps/desktop/src/app/settings/fallback-models-field.test.tsx b/apps/desktop/src/app/settings/fallback-models-field.test.tsx new file mode 100644 index 00000000000..1f2401010e6 --- /dev/null +++ b/apps/desktop/src/app/settings/fallback-models-field.test.tsx @@ -0,0 +1,128 @@ +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)) + }) + + it('keeps a draft row visible after autosave re-renders the same persisted chain', async () => { + const onChange = vi.fn() + const rerender = await renderFieldWithRerender([], onChange) + + fireEvent.click(screen.getByText('Add fallback')) + + expect(onChange.mock.calls.at(-1)?.[0]).toEqual([]) + expect(screen.getAllByLabelText('Remove')).toHaveLength(1) + + // Parent autosave echo — same complete chain, new array identity. + rerender([]) + + await waitFor(() => expect(screen.getAllByLabelText('Remove')).toHaveLength(1)) + }) +}) diff --git a/apps/desktop/src/app/settings/fallback-models-field.tsx b/apps/desktop/src/app/settings/fallback-models-field.tsx new file mode 100644 index 00000000000..0254492ae07 --- /dev/null +++ b/apps/desktop/src/app/settings/fallback-models-field.tsx @@ -0,0 +1,164 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useRef, 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: '' } + }) +} + +function completeEntries(rows: FallbackEntry[]): FallbackEntry[] { + return rows.filter(entry => entry.provider && entry.model) +} + +function entriesEqual(a: FallbackEntry[], b: FallbackEntry[]): boolean { + return a.length === b.length && a.every((entry, index) => entry.provider === b[index]?.provider && entry.model === b[index]?.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)) + // Last complete chain we emitted (or seeded). Autosave echoes the same + // filtered list back through `value`; ignore that echo so draft rows stay. + const lastEmittedRef = useRef(normalizeEntries(value)) + + // Resync on real external changes (profile switch / config reload). Skip + // when `value` is just our own commit echoing through the parent. + useEffect(() => { + const persisted = normalizeEntries(value) + + if (entriesEqual(persisted, lastEmittedRef.current)) { + return + } + + lastEmittedRef.current = persisted + setRows(persisted) + }, [value]) + + const commit = (next: FallbackEntry[]) => { + const complete = completeEntries(next) + + setRows(next) + lastEmittedRef.current = complete + onChange(complete) + } + + 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> + ) +} diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index aae1c75efe2..f7743203e5d 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -3,9 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global' +import { Tip } from '@/components/ui/tooltip' +import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global' import { useI18n } from '@/i18n' -import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons' +import { ExternalLink } from '@/lib/external-link' +import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons' +import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { $profiles, refreshActiveProfile } from '@/store/profile' @@ -13,9 +16,11 @@ import { $profiles, refreshActiveProfile } from '@/store/profile' import { CONTROL_TEXT } from './constants' import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives' -type Mode = 'local' | 'remote' +type Mode = 'local' | 'remote' | 'cloud' type AuthMode = 'oauth' | 'token' type ProbeStatus = 'idle' | 'probing' | 'done' | 'error' +// Hermes Cloud discovery lifecycle for the cloud-mode panel. +type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error' interface GatewaySettingsState { envOverride: boolean @@ -25,6 +30,7 @@ interface GatewaySettingsState { remoteTokenPreview: string | null remoteTokenSet: boolean remoteUrl: string + cloudOrg: string } const EMPTY_STATE: GatewaySettingsState = { @@ -34,13 +40,15 @@ const EMPTY_STATE: GatewaySettingsState = { remoteOauthConnected: false, remoteTokenPreview: null, remoteTokenSet: false, - remoteUrl: '' + remoteUrl: '', + cloudOrg: '' } function ModeCard({ active, description, disabled, + hint, icon: Icon, onSelect, title @@ -48,6 +56,7 @@ function ModeCard({ active: boolean description: string disabled?: boolean + hint?: string icon: typeof Monitor onSelect: () => void title: string @@ -55,22 +64,29 @@ function ModeCard({ return ( <button className={cn( - 'rounded-xl border p-3 text-left transition', - active - ? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)' - : 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)', - disabled && 'cursor-not-allowed opacity-50' + 'flex h-full min-h-0 w-full flex-col p-3 text-left disabled:cursor-not-allowed disabled:opacity-50', + selectableCardClass({ active, prominent: true }) )} disabled={disabled} onClick={onSelect} type="button" > - <div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium"> - <Icon className="size-4 text-muted-foreground" /> - <span>{title}</span> - {active ? <Check className="ml-auto size-4 text-primary" /> : null} + <div className="flex items-center gap-1.5"> + <Icon className="size-3.5 shrink-0 text-muted-foreground" /> + <span className="min-w-0 text-[length:var(--conversation-text-font-size)] font-medium">{title}</span> + {hint ? ( + <Tip label={hint}> + <span + className="grid size-3.5 shrink-0 cursor-help place-items-center text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)" + onClick={event => event.stopPropagation()} + > + <HelpCircle className="size-3.5" /> + </span> + </Tip> + ) : null} + {active ? <Check className="ml-auto size-3.5 shrink-0 text-primary" /> : null} </div> - <p className="mt-1.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + <p className="mt-1.5 flex-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> {description} </p> </button> @@ -94,7 +110,11 @@ function ScopeChip({ active, label, onSelect }: { active: boolean; label: string ) } -export function GatewaySettings() { +// `embedded` trims the page chrome for reuse inside the boot-failure recovery +// card: the outer title/intro, the "Save for next restart" action, and the +// Diagnostics row are redundant there (the card owns its header + a single +// reconnect action), so only the connection controls render. +export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {}) { const { t } = useI18n() const g = t.settings.gateway const [loading, setLoading] = useState(true) @@ -105,6 +125,32 @@ export function GatewaySettings() { const [remoteToken, setRemoteToken] = useState('') const [lastTest, setLastTest] = useState<null | string>(null) + // --- Hermes Cloud (cloud mode) state --- + // One portal session powers discovery + the silent per-agent cascade. These + // track the cloud panel: whether we're signed in, the discovered agent list, + // and which agent is mid-connect. + const [cloudSignedIn, setCloudSignedIn] = useState(false) + const [cloudSigningIn, setCloudSigningIn] = useState(false) + const [cloudAgents, setCloudAgents] = useState<DesktopCloudAgent[]>([]) + const [cloudDiscover, setCloudDiscover] = useState<CloudDiscoverStatus>('idle') + const [cloudConnectingId, setCloudConnectingId] = useState<null | string>(null) + // Multi-org users: when discovery returns needsOrgSelection, we hold the org + // list here and show a picker. `cloudOrg` is the chosen org slug/id (null = + // not yet chosen / single-org user). + const [cloudOrgs, setCloudOrgs] = useState<DesktopCloudOrg[]>([]) + const [cloudOrg, setCloudOrgState] = useState<null | string>(null) + // Mirror the selected org into a ref so connect reads the CURRENT value, not a + // value captured in a stale render closure. discoverCloud() resolves the org + // asynchronously (from the NAS response) and a user can click Connect in the + // same render tick; without the ref, connectCloudAgent could persist a null + // org even though discovery just resolved one. Always set both together. + const cloudOrgRef = useRef<null | string>(null) + + const setCloudOrg = (value: null | string) => { + cloudOrgRef.current = value + setCloudOrgState(value) + } + // Connection scope: null = the global/default connection (the original // behavior); a profile name = that profile's per-profile remote override, so // each profile can point at its own backend. @@ -163,6 +209,22 @@ export function GatewaySettings() { // OAuth login button or the session-token entry box. The effective auth mode // prefers a fresh probe result over the saved value. const trimmedUrl = state.remoteUrl.trim() + + // The dashboardUrl of the currently-connected cloud instance (the saved + // cloud connection's remoteUrl), normalized for comparison against each + // discovered agent's dashboardUrl so we can highlight the active one and hide + // its Connect button. Empty unless the saved connection is a cloud one. + // The saved cloud URL was stored via the main-side normalizeRemoteBaseUrl + // (which lowercases the host through URL.toString()), but a discovered agent's + // dashboardUrl arrives raw from NAS — so normalize both sides the same way + // (trim, drop trailing slash, lowercase) or a host-casing difference would + // silently break the connected-highlight. + const normalizeCloudUrl = (url: string) => url.trim().replace(/\/+$/, '').toLowerCase() + const connectedCloudUrl = state.mode === 'cloud' ? normalizeCloudUrl(state.remoteUrl) : '' + + const isConnectedAgent = (agent: DesktopCloudAgent) => + Boolean(connectedCloudUrl && agent.dashboardUrl && normalizeCloudUrl(agent.dashboardUrl) === connectedCloudUrl) + useEffect(() => { if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) { setProbeStatus('idle') @@ -379,6 +441,234 @@ export function GatewaySettings() { } } + // --- Hermes Cloud handlers --- + + // Pull the discovered agent list over the shared portal session. Tolerant of + // a lapsed session: a needsCloudLogin error flips us back to signed-out. + // `org` scopes discovery for multi-org users; when discovery comes back with + // needsOrgSelection we surface the org list and show a picker instead. + const discoverCloud = async (org?: string) => { + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudDiscover('loading') + + try { + const result = await desktop.cloud.discover(org) + + if ('needsOrgSelection' in result && result.needsOrgSelection) { + // Multi-org user with no org chosen yet: show the picker. Don't clear a + // previously-chosen org list on a refresh. + setCloudOrgs(result.orgs) + setCloudAgents([]) + setCloudDiscover('done') + + return + } + + // Single org (or org now chosen): we have agents. + setCloudAgents('agents' in result ? result.agents : []) + + // Record the org AUTHORITATIVELY from the response (NAS echoes the org the + // list was scoped to), falling back to the org we requested. This is what + // gets persisted on connect, so it must be set even on single-membership + // auto-resolve where no picker ran and no `org` arg was passed. + const resolvedOrgRef = 'org' in result && result.org ? (result.org.slug ?? result.org.id) : null + + if (resolvedOrgRef) { + setCloudOrg(resolvedOrgRef) + } else if (org) { + setCloudOrg(org) + } + + setCloudDiscover('done') + } catch (err) { + setCloudAgents([]) + setCloudDiscover('error') + + // A lapsed/absent portal session means we're effectively signed out. + if (err && typeof err === 'object' && 'needsCloudLogin' in err) { + setCloudSignedIn(false) + } + + notifyError(err, g.cloudDiscoverFailed) + } + } + + // User picked an org from the multi-org picker: remember it and re-run + // discovery scoped to it. + const selectCloudOrg = (org: DesktopCloudOrg) => { + const ref = org.slug ?? org.id + setCloudOrg(ref) + void discoverCloud(ref) + } + + // "Change org": clear the selected org and re-discover with no org arg. A + // multi-org user gets NAS's 409 → the picker; a single-org user auto-resolves + // back to their one org. Also clear the agent list so the current org's + // agents don't linger under the picker while discovery re-runs. + const changeCloudOrg = () => { + setCloudOrg(null) + setCloudAgents([]) + void discoverCloud() + } + + // On entering cloud mode (or scope change), read the portal session status and + // auto-discover when already signed in, so the picker is populated on open. + useEffect(() => { + if (state.mode !== 'cloud') { + return + } + + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + let cancelled = false + desktop.cloud + .status() + .then(status => { + if (cancelled) { + return + } + + setCloudSignedIn(status.signedIn) + + if (status.signedIn) { + // Restore the persisted org (if any) so we reopen straight into that + // org's agent list instead of the picker; discoverCloud(org) also + // records it as the selected org. Empty → normal discovery (single-org + // resolves automatically; multi-org shows the picker). + const savedOrg = state.cloudOrg || '' + + if (savedOrg) { + setCloudOrg(savedOrg) + } + + void discoverCloud(savedOrg || undefined) + } else { + setCloudAgents([]) + setCloudOrgs([]) + setCloudOrg(null) + setCloudDiscover('idle') + } + }) + .catch(() => { + if (!cancelled) { + setCloudSignedIn(false) + } + }) + + return () => void (cancelled = true) + // eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode/scope change only + }, [state.mode, scope]) + + const cloudSignIn = async () => { + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudSigningIn(true) + + try { + const result = await desktop.cloud.login() + setCloudSignedIn(result.signedIn) + + if (result.signedIn) { + await discoverCloud() + } + } catch (err) { + notifyError(err, g.cloudSignInFailed) + } finally { + setCloudSigningIn(false) + } + } + + const cloudSignOut = async () => { + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudSigningIn(true) + + try { + await desktop.cloud.logout() + setCloudSignedIn(false) + setCloudAgents([]) + setCloudOrgs([]) + setCloudOrg(null) + setCloudDiscover('idle') + notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage }) + } catch (err) { + notifyError(err, g.signOutFailed) + } finally { + setCloudSigningIn(false) + } + } + + // Select a discovered agent: drive the silent per-agent cascade (no second + // prompt — the shared portal session auto-approves), then persist a cloud-mode + // connection pointed at its dashboardUrl and apply it (soft-reconnects in place). + const connectCloudAgent = async (agent: DesktopCloudAgent) => { + if (!agent.dashboardUrl) { + return + } + + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudConnectingId(agent.id) + + try { + const result = await desktop.cloud.agentSignIn(agent.dashboardUrl) + + if (!result.connected) { + notify({ + kind: 'warning', + title: t.boot.failure.signInIncompleteTitle, + message: t.boot.failure.signInIncompleteMessage + }) + + return + } + + // Persist a cloud-mode connection (remote-shaped, oauth) and soft-reconnect. + // Include the selected org so Settings reopens into the same org + instance. + // Read the REF (not the cloudOrg state) so a just-resolved org from + // discovery in this same render tick is captured, not a stale null. + const next = await desktop.applyConnectionConfig({ + mode: 'cloud', + profile: scope ?? undefined, + remoteAuthMode: 'oauth', + remoteUrl: agent.dashboardUrl, + cloudOrg: cloudOrgRef.current ?? undefined + }) + + setState(next) + notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) }) + } catch (err) { + if (err && typeof err === 'object' && 'needsCloudLogin' in err) { + setCloudSignedIn(false) + } + + notifyError(err, g.cloudConnectFailed) + } finally { + setCloudConnectingId(null) + } + } + const testRemote = async () => { if (!canUseRemote) { notify({ @@ -421,17 +711,19 @@ export function GatewaySettings() { } return ( - <SettingsContent> - <div className="mb-5"> - <div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium"> - <Globe className="size-4 text-muted-foreground" /> - {g.title} - {state.envOverride ? <Pill tone="primary">{g.envOverride}</Pill> : null} + <SettingsContent bare={embedded}> + {embedded ? null : ( + <div className="mb-5"> + <div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium"> + <Globe className="size-4 text-muted-foreground" /> + {g.title} + {state.envOverride ? <Pill tone="primary">{g.envOverride}</Pill> : null} + </div> + <p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + {g.intro} + </p> </div> - <p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> - {g.intro} - </p> - </div> + )} {namedProfiles.length > 0 ? ( <div className="mb-5 grid gap-2"> @@ -465,144 +757,323 @@ export function GatewaySettings() { </div> ) : null} - <div className="grid gap-3 sm:grid-cols-2"> - <ModeCard - active={state.mode === 'local'} - description={g.localDesc} - disabled={state.envOverride} - icon={Monitor} - onSelect={() => setState(current => ({ ...current, mode: 'local' }))} - title={g.localTitle} - /> - <ModeCard - active={state.mode === 'remote'} - description={g.remoteDesc} - disabled={state.envOverride} - icon={Globe} - onSelect={() => setState(current => ({ ...current, mode: 'remote' }))} - title={g.remoteTitle} - /> + <div className="mb-5 grid gap-2"> + <div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)"> + {g.modeTitle} + </div> + <div className="grid auto-rows-fr grid-cols-1 gap-2 min-[42rem]:grid-cols-3"> + <ModeCard + active={state.mode === 'local'} + description={g.localDesc} + disabled={state.envOverride} + icon={Monitor} + onSelect={() => setState(current => ({ ...current, mode: 'local' }))} + title={g.localTitle} + /> + <ModeCard + active={state.mode === 'cloud'} + description={g.cloudDesc} + disabled={state.envOverride} + icon={Cloud} + onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))} + title={g.cloudTitle} + /> + <ModeCard + active={state.mode === 'remote'} + description={g.remoteDesc} + disabled={state.envOverride} + hint={g.remoteAuthHint} + icon={Globe} + onSelect={() => setState(current => ({ ...current, mode: 'remote' }))} + title={g.remoteTitle} + /> + </div> </div> - <div className="mt-5 grid gap-1"> - <ListRow - action={ - <Input - className={cn('h-8', CONTROL_TEXT)} - disabled={state.envOverride} - onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))} - placeholder="https://gateway.example.com/hermes" - value={state.remoteUrl} - /> - } - description={g.remoteUrlDesc} - title={g.remoteUrlTitle} - /> - - {state.mode === 'remote' && probeStatus === 'probing' ? ( - <div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> - <Loader2 className="size-4 animate-spin" /> - {g.probing} - </div> - ) : null} - - {state.mode === 'remote' && probeStatus === 'error' ? ( - <div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> - <AlertCircle className="mt-0.5 size-4 shrink-0" /> - {g.probeError} - </div> - ) : null} - - {/* OAuth / password gateways: present a sign-in button + connection status. */} - {state.mode === 'remote' && authResolved && authMode === 'oauth' ? ( + {/* Hermes Cloud panel: one portal sign-in, then a discovered-agent picker + whose selection drives the silent per-agent cascade + a cloud + connection. Replaces the URL/token form while in cloud mode. */} + {state.mode === 'cloud' && !state.envOverride ? ( + <div className="mt-5 grid gap-1"> <ListRow action={ - oauthConnected ? ( + cloudSignedIn ? ( <div className="flex items-center gap-2"> <Pill tone="primary"> - <Check className="size-3" /> {g.signedIn} + <Check className="size-3" /> {g.cloudSignedIn} </Pill> - <Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline"> - {signingIn ? <Loader2 className="animate-spin" /> : null} + <Button disabled={cloudSigningIn} onClick={() => void cloudSignOut()} variant="outline"> + {cloudSigningIn ? <Loader2 className="animate-spin" /> : null} {g.signOut} </Button> </div> ) : ( - <Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}> - {signingIn ? <Loader2 className="animate-spin" /> : <LogIn />} - {isPasswordProvider ? g.signIn : g.signInWith(providerLabel)} + <Button disabled={cloudSigningIn} onClick={() => void cloudSignIn()}> + {cloudSigningIn ? <Loader2 className="animate-spin" /> : <LogIn />} + {g.cloudSignIn} </Button> ) } - description={ - oauthConnected - ? isPasswordProvider - ? g.authSignedInPassword - : g.authSignedInOauth - : isPasswordProvider - ? g.authNeedsPassword - : g.authNeedsOauth(providerLabel) - } - title={g.authTitle} + description={cloudSignedIn ? g.cloudSignedInDesc : g.cloudNeedsSignIn} + title={g.cloudSignInTitle} /> - ) : null} - {/* Session-token gateways: keep the existing token entry box. */} - {state.mode === 'remote' && authResolved && authMode === 'token' ? ( + {cloudSignedIn ? ( + cloudOrgs.length > 0 && !cloudOrg ? ( + // Multi-org user who hasn't picked an org yet: show the org picker + // instead of the agent list. Selecting one re-runs discovery + // scoped to it. + <div className="mt-3"> + <div className="mb-2 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)"> + {g.cloudOrgPickerTitle} + </div> + <div className="grid gap-1"> + {cloudOrgs.map(orgEntry => ( + <ListRow + action={ + <Button onClick={() => selectCloudOrg(orgEntry)} size="sm"> + {g.cloudOrgSelect} + </Button> + } + description={g.cloudOrgRole(orgEntry.role)} + key={orgEntry.id} + title={orgEntry.name} + /> + ))} + </div> + </div> + ) : ( + <div className="mt-3"> + <div className="mb-2 flex items-center justify-between"> + <div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)"> + {g.cloudAgentsTitle} + </div> + <div className="flex items-center gap-2"> + {cloudOrg ? ( + // Let the user switch orgs. Gating on cloudOrgs.length would + // hide this after a restore-open (which discovers straight + // into the saved org and never populates the org list). So + // show it whenever an org is selected: clicking clears the + // org and re-runs discovery with no org arg — a multi-org + // user gets the picker (NAS 409), a single-org user simply + // auto-resolves back to their one org (harmless). + <Button onClick={() => changeCloudOrg()} size="sm" variant="text"> + {g.cloudOrgChange} + </Button> + ) : null} + <Button + disabled={cloudDiscover === 'loading'} + onClick={() => void discoverCloud(cloudOrg ?? undefined)} + size="sm" + variant="text" + > + {cloudDiscover === 'loading' ? <Loader2 className="animate-spin" /> : <RefreshCw />} + {g.cloudRefresh} + </Button> + </div> + </div> + + {cloudDiscover === 'loading' ? ( + <div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> + <Loader2 className="size-4 animate-spin" /> + {g.cloudLoadingAgents} + </div> + ) : cloudAgents.length === 0 ? ( + <div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> + <AlertCircle className="mt-0.5 size-4 shrink-0" /> + <span> + {g.cloudNoAgents.before} + <ExternalLink href="https://portal.nousresearch.com/agents" showExternalIcon={false}> + {g.cloudNoAgents.linkText} + </ExternalLink> + {g.cloudNoAgents.after} + </span> + </div> + ) : ( + <div className="grid gap-1"> + {cloudAgents.map(agent => { + const connected = isConnectedAgent(agent) + + return ( + <div + className={cn('rounded-md px-2', connected && 'bg-primary/5 ring-1 ring-primary/25')} + key={agent.id} + > + <ListRow + action={ + connected ? ( + <Pill tone="primary"> + <Check className="mr-1 inline size-3" /> + {g.cloudConnectedPill} + </Pill> + ) : ( + <Button + disabled={!agent.dashboardUrl || cloudConnectingId !== null} + onClick={() => void connectCloudAgent(agent)} + size="sm" + > + {cloudConnectingId === agent.id ? <Loader2 className="animate-spin" /> : null} + {agent.dashboardUrl + ? cloudConnectingId === agent.id + ? g.cloudConnecting + : g.cloudConnect + : g.cloudAgentProvisioning} + </Button> + ) + } + description={g.cloudStatusLabel(agent.dashboardGatewayState)} + title={agent.name} + /> + </div> + ) + })} + </div> + )} + </div> + ) + ) : null} + </div> + ) : null} + + {state.mode === 'remote' && !state.envOverride ? ( + <div className="mt-5 grid gap-1"> <ListRow action={ <Input - autoComplete="off" - className={cn('h-8 font-mono', CONTROL_TEXT)} + className={cn('h-8', CONTROL_TEXT)} disabled={state.envOverride} - onChange={event => setRemoteToken(event.target.value)} - placeholder={ - state.remoteTokenSet ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) : g.pasteSessionToken - } - type="password" - value={remoteToken} + onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))} + placeholder="https://gateway.example.com/hermes" + value={state.remoteUrl} /> } - description={g.tokenDesc} - title={g.tokenTitle} + description={g.remoteUrlDesc} + title={g.remoteUrlTitle} /> - ) : null} - </div> + + {state.mode === 'remote' && probeStatus === 'probing' ? ( + <div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> + <Loader2 className="size-4 animate-spin" /> + {g.probing} + </div> + ) : null} + + {state.mode === 'remote' && probeStatus === 'error' ? ( + <div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> + <AlertCircle className="mt-0.5 size-4 shrink-0" /> + {g.probeError} + </div> + ) : null} + + {/* OAuth / password gateways: present a sign-in button + connection status. */} + {state.mode === 'remote' && authResolved && authMode === 'oauth' ? ( + <ListRow + action={ + oauthConnected ? ( + <div className="flex items-center gap-2"> + <Pill tone="primary"> + <Check className="size-3" /> {g.signedIn} + </Pill> + <Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline"> + {signingIn ? <Loader2 className="animate-spin" /> : null} + {g.signOut} + </Button> + </div> + ) : ( + <Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}> + {signingIn ? <Loader2 className="animate-spin" /> : <LogIn />} + {isPasswordProvider ? g.signIn : g.signInWith(providerLabel)} + </Button> + ) + } + description={ + oauthConnected + ? isPasswordProvider + ? g.authSignedInPassword + : g.authSignedInOauth + : isPasswordProvider + ? g.authNeedsPassword + : g.authNeedsOauth(providerLabel) + } + title={g.authTitle} + /> + ) : null} + + {/* Session-token gateways: keep the existing token entry box. */} + {state.mode === 'remote' && authResolved && authMode === 'token' ? ( + <ListRow + action={ + <Input + autoComplete="off" + className={cn('h-8 font-mono', CONTROL_TEXT)} + disabled={state.envOverride} + onChange={event => setRemoteToken(event.target.value)} + placeholder={ + state.remoteTokenSet + ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) + : g.pasteSessionToken + } + type="password" + value={remoteToken} + /> + } + description={g.tokenDesc} + title={g.tokenTitle} + /> + ) : null} + </div> + ) : null} {lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null} - <div className="mt-6 flex flex-wrap items-center justify-end gap-4"> - <Button - className="mr-auto" - disabled={state.envOverride || testing || !canUseRemote} - onClick={() => void testRemote()} - size="sm" - variant="text" - > - {testing ? <Loader2 className="animate-spin" /> : null} - {g.testRemote} - </Button> - <Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong"> - {g.saveForRestart} - </Button> - <Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm"> - {saving ? <Loader2 className="animate-spin" /> : null} - {g.saveAndReconnect} - </Button> - </div> - - <div className="mt-6 grid gap-1"> - <ListRow - action={ - <Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong"> - <FileText /> - {g.openLogs} + {/* Test/Save apply to local + remote. Cloud connects via the agent picker + above (which applies a cloud connection on select), so its only + bottom-row action would be redundant — hidden in cloud mode. */} + {state.mode !== 'cloud' ? ( + <div className="mt-6 flex flex-wrap items-center justify-end gap-4"> + {state.mode === 'remote' ? ( + <Button + className="mr-auto" + disabled={state.envOverride || testing || !canUseRemote} + onClick={() => void testRemote()} + size="sm" + variant="text" + > + {testing ? <Loader2 className="animate-spin" /> : null} + {g.testRemote} </Button> - } - description={g.diagnosticsDesc} - title={g.diagnostics} - /> - </div> + ) : null} + {embedded ? null : ( + <Button + disabled={state.envOverride || saving} + onClick={() => void save(false)} + size="sm" + variant="textStrong" + > + {g.saveForRestart} + </Button> + )} + <Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm"> + {saving ? <Loader2 className="animate-spin" /> : null} + {g.saveAndReconnect} + </Button> + </div> + ) : null} + + {embedded ? null : ( + <div className="mt-6 grid gap-1"> + <ListRow + action={ + <Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong"> + <FileText /> + {g.openLogs} + </Button> + } + description={g.diagnosticsDesc} + title={g.diagnostics} + /> + </div> + )} </SettingsContent> ) } diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 135e9e268f2..51c48103388 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -1,3 +1,4 @@ +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' @@ -32,7 +33,8 @@ vi.mock('@/hermes', () => ({ saveMoaModels: (body: unknown) => saveMoaModels(body), setEnvVar: (key: string, value: string) => setEnvVar(key, value), getHermesConfigRecord: () => getHermesConfigRecord(), - saveHermesConfig: (config: unknown) => saveHermesConfig(config) + saveHermesConfig: (config: unknown) => saveHermesConfig(config), + setApiRequestProfile: () => {} })) vi.mock('@/store/onboarding', () => ({ @@ -71,8 +73,13 @@ afterEach(() => { async function renderModelSettings() { const { ModelSettings } = await import('./model-settings') + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - return render(<ModelSettings />) + return render( + <QueryClientProvider client={client}> + <ModelSettings /> + </QueryClientProvider> + ) } describe('ModelSettings', () => { diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 01c57f9925b..fa7d15a5d1e 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -82,7 +82,7 @@ export function ModelSettingsSkeleton() { // Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off. // Empty config = Hermes default (medium), shown as Medium. -const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const +const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const // agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is // normal (mirrors tui_gateway _load_service_tier). @@ -93,8 +93,8 @@ const isFastTier = (tier: unknown): boolean => .toLowerCase() ) -// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1). -const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal' +// Reuse the composer's effort labels. +const effortLabelKey = (v: string) => v as 'high' | 'low' | 'max' | 'medium' | 'minimal' | 'ultra' | 'xhigh' // A provider row is "ready" to pick a model from when it reports models. The // backend now surfaces the full `hermes model` universe (every canonical @@ -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. diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index beeddf32c38..27fefa5d484 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -8,10 +8,14 @@ import { cn } from '@/lib/utils' import { PAGE_INSET_X } from '../layout-constants' -export function SettingsContent({ children }: { children: ReactNode }) { +// `bare` drops the page gutters + tall bottom pad for embedding in a tighter +// surface (e.g. the boot-failure recovery card owns its own padding). +export function SettingsContent({ children, bare = false }: { children: ReactNode; bare?: boolean }) { return ( <section className="min-h-0 overflow-hidden"> - <div className={cn('h-full min-h-0 overflow-y-auto pb-20', PAGE_INSET_X)}>{children}</div> + <div className={cn('h-full min-h-0 overflow-y-auto', bare ? 'px-5 pb-6' : cn('pb-20', PAGE_INSET_X))}> + {children} + </div> </section> ) } diff --git a/apps/desktop/src/app/settings/provider-config-panel.test.tsx b/apps/desktop/src/app/settings/provider-config-panel.test.tsx index 774d45f4079..fa95f9dcd70 100644 --- a/apps/desktop/src/app/settings/provider-config-panel.test.tsx +++ b/apps/desktop/src/app/settings/provider-config-panel.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MemoryProviderConfig } from '@/types/hermes' @@ -97,7 +97,12 @@ afterEach(() => { async function renderPanel(provider = 'hindsight') { const { ProviderConfigPanel } = await import('./provider-config-panel') - return render(<ProviderConfigPanel provider={provider} />) + let result: ReturnType<typeof render> + await act(async () => { + result = render(<ProviderConfigPanel provider={provider} />) + }) + + return result! } describe('ProviderConfigPanel', () => { @@ -115,9 +120,13 @@ describe('ProviderConfigPanel', () => { await renderPanel() expect(await screen.findByLabelText('API URL')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ })) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ })) + }) expect(screen.queryByLabelText('API URL')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ })) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ })) + }) expect(await screen.findByLabelText('API URL')).toBeTruthy() }) @@ -125,9 +134,11 @@ describe('ProviderConfigPanel', () => { await renderPanel() const apiUrl = await screen.findByLabelText('API URL') - fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } }) - fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } }) - fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await act(async () => { + fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } }) + fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + }) await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', { diff --git a/apps/desktop/src/app/settings/providers-settings.test.tsx b/apps/desktop/src/app/settings/providers-settings.test.tsx index 8a894e27ab1..ac63be962ae 100644 --- a/apps/desktop/src/app/settings/providers-settings.test.tsx +++ b/apps/desktop/src/app/settings/providers-settings.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { atom } from 'nanostores' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -73,8 +73,12 @@ afterEach(() => { async function renderProvidersSettings() { const { ProvidersSettings } = await import('./providers-settings') + let result: ReturnType<typeof render> + await act(async () => { + result = render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />) + }) - return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />) + return result! } describe('ProvidersSettings', () => { @@ -82,7 +86,9 @@ describe('ProvidersSettings', () => { await renderProvidersSettings() const remove = await screen.findByRole('button', { name: 'Remove Nous Portal' }) - fireEvent.click(remove) + await act(async () => { + fireEvent.click(remove) + }) await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous')) expect(listOAuthProviders).toHaveBeenCalledTimes(2) @@ -91,7 +97,9 @@ describe('ProvidersSettings', () => { it('keeps provider selection separate from account removal', async () => { await renderProvidersSettings() - fireEvent.click(await screen.findByText('Nous Portal')) + await act(async () => { + fireEvent.click(await screen.findByText('Nous Portal')) + }) expect(startManualProviderOAuth).toHaveBeenCalledWith('nous') expect(disconnectOAuthProvider).not.toHaveBeenCalled() @@ -132,7 +140,9 @@ describe('ProvidersSettings', () => { listOAuthProviders.mockResolvedValue({ providers: [] }) const { ProvidersSettings } = await import('./providers-settings') - render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />) + await act(async () => { + render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />) + }) expect(await screen.findByText('WidgetAI')).toBeTruthy() }) @@ -158,14 +168,18 @@ describe('ProvidersSettings', () => { // Typing narrows the list to matching providers only. const search = screen.getByPlaceholderText('Search providers…') - fireEvent.change(search, { target: { value: 'mid' } }) + await act(async () => { + fireEvent.change(search, { target: { value: 'mid' } }) + }) await waitFor(() => expect(screen.queryByText('Acme')).toBeNull()) expect(screen.getByText('Middle')).toBeTruthy() expect(screen.queryByText('Zebra')).toBeNull() // A non-matching query shows the empty-state copy. - fireEvent.change(search, { target: { value: 'nonesuch-xyz' } }) + await act(async () => { + fireEvent.change(search, { target: { value: 'nonesuch-xyz' } }) + }) expect(await screen.findByText('No providers match your search.')).toBeTruthy() }) }) diff --git a/apps/desktop/src/app/shell/approval-mode-menu.test.tsx b/apps/desktop/src/app/shell/approval-mode-menu.test.tsx new file mode 100644 index 00000000000..05a122f8bf6 --- /dev/null +++ b/apps/desktop/src/app/shell/approval-mode-menu.test.tsx @@ -0,0 +1,89 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { StatusbarControls } from '@/app/shell/statusbar-controls' +import { I18nProvider } from '@/i18n' +import { $approvalModes } from '@/store/approval-mode' + +import { useApprovalModeStatusbarItem } from './approval-mode-menu' + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +beforeAll(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver) + Element.prototype.hasPointerCapture ??= () => false + Element.prototype.setPointerCapture ??= () => undefined + Element.prototype.releasePointerCapture ??= () => undefined + HTMLElement.prototype.scrollIntoView ??= () => undefined +}) + +afterEach(() => { + cleanup() + $approvalModes.set({}) +}) + +function Harness({ + profile = 'default', + requestGateway +}: { + profile?: string + requestGateway: (method: string, params?: Record<string, unknown>) => Promise<unknown> +}) { + const item = useApprovalModeStatusbarItem(profile, requestGateway) + + return ( + <MemoryRouter> + <StatusbarControls items={[item]} /> + </MemoryRouter> + ) +} + +describe('approval mode statusbar item', () => { + it('uses the shared statusbar menu trigger without a nested bespoke button', async () => { + const response = new Promise<never>(() => undefined) + render(<Harness requestGateway={vi.fn(() => response)} />) + + const statusbar = screen.getByRole('contentinfo') + const trigger = within(statusbar).getByRole('button', { name: /smart/i }) + expect(within(statusbar).getAllByRole('button')).toHaveLength(1) + + fireEvent.pointerDown(trigger, { button: 0 }) + + expect(await screen.findByRole('menuitemradio', { name: /manual/i })).toBeTruthy() + expect(trigger.getAttribute('aria-haspopup')).toBe('menu') + expect(screen.getByRole('menuitemradio', { name: /smart/i })).toBeTruthy() + expect(screen.getByRole('menuitemradio', { name: /off/i })).toBeTruthy() + }) + + it('writes the selected mode through the gateway and updates its shared trigger label', async () => { + const requestGateway = vi.fn(async (_method, params) => ({ value: params?.value ?? 'smart' })) + render(<Harness profile="work" requestGateway={requestGateway} />) + + fireEvent.pointerDown(screen.getByRole('button', { name: /smart/i }), { button: 0 }) + fireEvent.click(await screen.findByRole('menuitemradio', { name: /manual/i })) + + await waitFor(() => { + expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'approvals.mode', value: 'manual' }) + expect(screen.getByRole('button', { name: /manual/i })).toBeTruthy() + }) + }) + + it('renders the shared trigger and menu in the active locale', async () => { + const response = new Promise<never>(() => undefined) + render( + <I18nProvider configClient={null} initialLocale="ja"> + <Harness requestGateway={vi.fn(() => response)} /> + </I18nProvider> + ) + + fireEvent.pointerDown(screen.getByRole('button', { name: 'スマート' }), { button: 0 }) + + expect(await screen.findByText('必要な場合にのみ確認します')).toBeTruthy() + expect(screen.getByText('承認プロンプトなしで実行します')).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/shell/approval-mode-menu.tsx b/apps/desktop/src/app/shell/approval-mode-menu.tsx new file mode 100644 index 00000000000..40f9f236a18 --- /dev/null +++ b/apps/desktop/src/app/shell/approval-mode-menu.tsx @@ -0,0 +1,81 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useMemo } from 'react' + +import type { StatusbarItem } from '@/app/shell/statusbar-controls' +import { + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator +} from '@/components/ui/dropdown-menu' +import { useI18n } from '@/i18n' +import { Zap, ZapFilled } from '@/lib/icons' +import { + $approvalModes, + type ApprovalMode, + type ApprovalModeRequester, + setApprovalModeForProfile, + syncApprovalModeForProfile +} from '@/store/approval-mode' + +export function useApprovalModeStatusbarItem( + profile: string, + requestGateway: ApprovalModeRequester +): StatusbarItem { + const { t } = useI18n() + const copy = t.shell.approvalMode + const modes = useStore($approvalModes) + const mode = modes[profile.trim() || 'default'] ?? 'smart' + + const labels = useMemo<Record<ApprovalMode, string>>( + () => ({ manual: copy.manual, smart: copy.smart, off: copy.off }), + [copy.manual, copy.off, copy.smart] + ) + + const descriptions = useMemo<Record<ApprovalMode, string>>( + () => ({ + manual: copy.manualDescription, + smart: copy.smartDescription, + off: copy.offDescription + }), + [copy.manualDescription, copy.offDescription, copy.smartDescription] + ) + + useEffect(() => { + void syncApprovalModeForProfile(requestGateway, profile).catch(() => undefined) + }, [profile, requestGateway]) + + return { + className: mode === 'off' ? 'bg-(--chrome-action-hover) text-foreground' : undefined, + icon: mode === 'off' ? <ZapFilled className="size-3.5" /> : <Zap className="size-3.5 opacity-70" />, + id: 'approval-mode', + label: labels[mode], + menuAlign: 'end', + menuClassName: 'w-72 p-1', + menuContent: ( + <> + <DropdownMenuLabel>{copy.title}</DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuRadioGroup + onValueChange={value => { + void setApprovalModeForProfile(requestGateway, profile, value as ApprovalMode).catch(() => undefined) + }} + value={mode} + > + {(['manual', 'smart', 'off'] as const).map(value => ( + <DropdownMenuRadioItem className="items-start gap-2" key={value} value={value}> + <span className="flex min-w-0 flex-col gap-0.5"> + <span className="text-xs text-foreground">{labels[value]}</span> + <span className="text-[0.6875rem] leading-snug text-(--ui-text-tertiary)"> + {descriptions[value]} + </span> + </span> + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </> + ), + title: copy.ariaLabel(labels[mode]), + variant: 'menu' + } +} diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 753c3893bbd..021832021fd 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -1,27 +1,29 @@ import { useStore } from '@nanostores/react' -import { useCallback, useMemo } from 'react' +import { useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store' +import { useApprovalModeStatusbarItem } from '@/app/shell/approval-mode-menu' import { ContextUsagePanel } from '@/app/shell/context-usage-panel' import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' -import { Activity, AlertCircle, Clock, Command, Hash, Loader2, Terminal, Zap, ZapFilled } from '@/lib/icons' +import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal } from '@/lib/icons' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' -import { setGlobalYolo, setSessionYolo } from '@/lib/yolo-session' +import { copyFilePath, revealFile } from '@/store/file-actions' +import { revealFileInTree } from '@/store/layout' +import { $activeGatewayProfile } from '@/store/profile' import { $activeSessionId, $busy, $connection, + $currentCwd, $currentUsage, $sessionStartedAt, $turnStartedAt, - $yoloActive, - setYoloActive } from '@/store/session' import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents' import { $gatewayRestarting } from '@/store/system-actions' @@ -36,7 +38,14 @@ import { import type { StatusResponse } from '@/types/hermes' import { CRON_ROUTE } from '../../routes' -import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-controls' +import type { StatusbarItem } from '../statusbar-controls' + +function workspaceLabel(cwd: string): string { + const normalized = cwd.replace(/[\\/]+$/, '') + const leaf = normalized.split(/[\\/]/).filter(Boolean).pop() + + return leaf || cwd +} interface StatusbarItemsOptions { agentsOpen: boolean @@ -64,17 +73,18 @@ export function useStatusbarItems({ inferenceStatus, openAgents, openCommandCenterSection, - freshDraftReady, requestGateway, statusSnapshot, toggleCommandCenter }: StatusbarItemsOptions) { const { t } = useI18n() const copy = t.shell.statusbar + const fileMenu = t.fileMenu const activeSessionId = useStore($activeSessionId) + const activeGatewayProfile = useStore($activeGatewayProfile) const terminalTakeover = useStore($terminalTakeover) - const yoloActive = useStore($yoloActive) const busy = useStore($busy) + const currentCwd = useStore($currentCwd) const currentUsage = useStore($currentUsage) const gatewayRestarting = useStore($gatewayRestarting) const sessionStartedAt = useStore($sessionStartedAt) @@ -89,45 +99,8 @@ export function useStatusbarItems({ const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage]) const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage]) + const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway) - // Per-session approval bypass (same scope as the TUI's Shift+Tab). On a - // new-chat draft (no runtime session yet) we arm locally; the session-create - // path applies it once the backend session exists. - // - // Shift+click flips the GLOBAL approvals.mode instead — a persistent, - // all-sessions/CLI/TUI/cron bypass that survives restarts. - const toggleYolo = useCallback( - async (modifiers?: StatusbarSelectModifiers) => { - const next = !$yoloActive.get() - - setYoloActive(next) - - if (modifiers?.shiftKey) { - try { - await setGlobalYolo(requestGateway, next) - } catch { - setYoloActive(!next) - } - - return - } - - const sid = $activeSessionId.get() - - if (!sid) { - return - } - - try { - await setSessionYolo(requestGateway, sid, next) - } catch { - setYoloActive(!next) - } - }, - [requestGateway] - ) - - const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady) const gatewayMenuContent = useMemo( () => (close: () => void) => ( @@ -299,6 +272,36 @@ export function useStatusbarItems({ title: inferenceStatus?.reason || copy.gatewayTitle, variant: 'menu' }, + { + hidden: !currentCwd, + icon: <FolderOpen className="size-3" />, + id: 'workspace-cwd', + label: currentCwd ? workspaceLabel(currentCwd) : undefined, + menuItems: currentCwd + ? [ + { + id: 'copy-workspace-path', + label: fileMenu.copyPath, + onSelect: () => void copyFilePath(currentCwd), + title: currentCwd + }, + { + id: 'reveal-workspace-finder', + label: fileMenu.revealFileManager, + onSelect: () => void revealFile(currentCwd), + title: currentCwd + }, + { + id: 'reveal-workspace-sidebar', + label: fileMenu.revealInSidebar, + onSelect: () => revealFileInTree(currentCwd), + title: currentCwd + } + ] + : undefined, + title: currentCwd || undefined, + variant: 'menu' + }, { className: cn( agentsOpen && 'bg-accent/55 text-foreground', @@ -337,6 +340,10 @@ export function useStatusbarItems({ agentsOpen, commandCenterOpen, copy, + currentCwd, + fileMenu.copyPath, + fileMenu.revealFileManager, + fileMenu.revealInSidebar, gatewayMenuContent, gatewayClassName, gatewayDetail, @@ -383,17 +390,8 @@ export function useStatusbarItems({ variant: 'text' }, { - className: cn('px-1', yoloActive && 'bg-(--chrome-action-hover)'), - hidden: !showYoloToggle, - icon: yoloActive ? ( - <ZapFilled className="size-3.5 shrink-0" /> - ) : ( - <Zap className="size-3.5 shrink-0 opacity-70" /> - ), - id: 'yolo', - onSelect: modifiers => void toggleYolo(modifiers), - title: yoloActive ? copy.yoloOn : copy.yoloOff, - variant: 'action' + ...approvalModeItem, + hidden: gatewayState !== 'open', }, { className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`, @@ -409,6 +407,7 @@ export function useStatusbarItems({ ], [ activeSessionId, + approvalModeItem, backendVersionItem, busy, chatOpen, @@ -419,11 +418,9 @@ export function useStatusbarItems({ currentUsage, requestGateway, sessionStartedAt, - showYoloToggle, + gatewayState, terminalTakeover, - toggleYolo, turnStartedAt, - yoloActive ] ) diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index cf2a8af660f..dda409699f5 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -24,7 +24,9 @@ const EFFORT_OPTIONS = [ { value: 'low', labelKey: 'low' }, { value: 'medium', labelKey: 'medium' }, { value: 'high', labelKey: 'high' }, - { value: 'xhigh', labelKey: 'max' } + { value: 'xhigh', labelKey: 'xhigh' }, + { value: 'max', labelKey: 'max' }, + { value: 'ultra', labelKey: 'ultra' } ] as const /** How "fast" is achieved for a given model — two different mechanisms: diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx index 57125de35da..7ca78274f34 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { cleanup, findByText, fireEvent, render } from '@testing-library/react' +import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu' @@ -39,7 +39,8 @@ afterEach(() => { function renderPanel(onSelectModel = vi.fn()) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - render( + + const content = render( <QueryClientProvider client={client}> <DropdownMenu open> <DropdownMenuContent> @@ -49,50 +50,55 @@ function renderPanel(onSelectModel = vi.fn()) { </QueryClientProvider> ) - return onSelectModel + return { onSelectModel, content } } describe('ModelMenuPanel MoA presets', () => { it('selecting a MoA preset switches PERSISTENTLY via onSelectModel (not the one-shot dispatch)', async () => { - const onSelectModel = renderPanel() + const { content, onSelectModel } = renderPanel() // moaOptions is async (useQuery) — wait for the preset row to mount. - const row = await findByText(document.body, 'MoA: BeastMode') + const row = await content.findByText('MoA: BeastMode') fireEvent.click(row) // #54670: must route through the persistent model-switch path - // (config.set model="<preset> --provider moa"), i.e. onSelectModel with - // provider 'moa', NOT a one-shot command.dispatch that reverts after a turn. + // i.e. onSelectModel with provider 'moa' (which session-scopes live-session + // switches), NOT a one-shot command.dispatch that reverts after a turn. expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa' }) }) it('shows the check on the preset that matches the current moa selection', async () => { $currentProvider.set('moa') $currentModel.set('BeastMode') - renderPanel() + const { content } = renderPanel() - const row = await findByText(document.body, 'MoA: BeastMode') + const row = await content.findByText('MoA: BeastMode') // The check codicon renders as a sibling within the same row item. const item = row.closest('[role="menuitem"]') ?? row.parentElement expect(item?.querySelector('.codicon-check')).not.toBeNull() }) it('keeps the virtual moa provider out of the main model groups (presets section only)', async () => { - renderPanel() + const { content } = renderPanel() - await findByText(document.body, 'MoA: BeastMode') + await content.findByText('MoA: BeastMode') // The provider group header would read "Mixture of Agents"; the presets // section header reads "MoA presets". Only the latter should exist. + // Radix DropdownMenu portals its content to document.body, so assert + // against the body (not content.container) to see the rendered items. + + // eslint-disable-next-line no-restricted-globals expect(document.body.textContent).toContain('MoA presets') + // eslint-disable-next-line no-restricted-globals expect(document.body.textContent).not.toContain('Mixture of Agents') }) it('renders presets from the catalog even before a session exists', async () => { $activeSessionId.set('') - const onSelectModel = renderPanel() + const { onSelectModel, content } = renderPanel() - const row = await findByText(document.body, 'MoA: BeastMode') + const row = await content.findByText('MoA: BeastMode') fireEvent.click(row) // Pre-session picks are UI state shipped on the next session.create — the diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index f358a29ffab..eec7385af8c 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -180,8 +180,8 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model } // Selecting a MoA preset switches the session to it PERSISTENTLY, using the - // same path real provider selections use (config.set model="<preset> - // --provider moa" via onSelectModel → the gateway's persistent switch_model). + // same path real provider selections use (onSelectModel → config.set with + // --session for live sessions → the gateway's persistent switch_model). // Previously this dispatched the one-shot `/moa` command, which ran a single // turn through MoA and then silently reverted to the prior model (#54670) — // the dropdown presented presets like persistent selections but they weren't. diff --git a/apps/desktop/src/app/skills/hub.tsx b/apps/desktop/src/app/skills/hub.tsx index a7ed3ca8fe5..1af1b672d26 100644 --- a/apps/desktop/src/app/skills/hub.tsx +++ b/apps/desktop/src/app/skills/hub.tsx @@ -323,7 +323,9 @@ export function SkillsHub({ query }: SkillsHubProps) { <div className="flex shrink-0 items-center justify-between gap-3 px-4 pb-1.5 text-[0.68rem] text-(--ui-text-tertiary)"> <span className="min-w-0 truncate"> {term.length > 0 ? h.resultCount(results.length, null) : h.featured} - {anyFetching && results.length > 0 && <span className="ml-2 text-(--ui-text-quaternary)">{h.searching}</span>} + {anyFetching && results.length > 0 && ( + <span className="ml-2 text-(--ui-text-quaternary)">{h.searching}</span> + )} </span> {hasInstalled && ( diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/skills/index.test.tsx index fe3e39a72c7..f4057a4f2b8 100644 --- a/apps/desktop/src/app/skills/index.test.tsx +++ b/apps/desktop/src/app/skills/index.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { QueryClientProvider } from '@tanstack/react-query' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -48,9 +48,11 @@ function toolset(overrides: Record<string, unknown> = {}) { } } -function renderSkills() { - return import('./index').then(({ SkillsView }) => - render( +async function renderSkills() { + const { SkillsView } = await import('./index') + let result: ReturnType<typeof render> + await act(async () => { + result = render( // SkillsView reads skills/toolsets via useQuery, so it needs a provider. <QueryClientProvider client={queryClient}> <MemoryRouter initialEntries={['/skills?tab=toolsets']}> @@ -58,7 +60,9 @@ function renderSkills() { </MemoryRouter> </QueryClientProvider> ) - ) + }) + + return result! } beforeEach(() => { @@ -83,7 +87,9 @@ describe('SkillsView toolset management', () => { const sw = await screen.findByRole('switch', { name: 'Toggle Web Search toolset' }) expect(sw.getAttribute('aria-checked')).toBe('true') - fireEvent.click(sw) + await act(async () => { + fireEvent.click(sw) + }) await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false)) }) diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 41620e2c124..ccbcd5c9b41 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -492,7 +492,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p try { await editLearningNode(skillEditor.name, skillDraft) - notify({ kind: 'success', title: t.skills.skillUpdated, message: t.skills.appliesToNewSessions(skillEditor.name) }) + notify({ + kind: 'success', + title: t.skills.skillUpdated, + message: t.skills.appliesToNewSessions(skillEditor.name) + }) setSkillEditor(null) void refreshCapabilities() } catch (err) { @@ -577,7 +581,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p left={sortButton(skillsSortDesc, () => $skillsSortDesc.set(!$skillsSortDesc.get()))} right={ <ListStripMenu - items={[{ disabled: bulkBusy, label: t.skills.disableUnused, onSelect: () => void disableUnused() }]} + items={[ + { disabled: bulkBusy, label: t.skills.disableUnused, onSelect: () => void disableUnused() } + ]} label={t.skills.tabSkills} toggle={bulkSwitch(allSkillsEnabled)} /> diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx new file mode 100644 index 00000000000..a508b8471c5 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx @@ -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() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index ed3ee6be8d4..967c9aac2e4 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -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} /> )} diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index bceadb8e6ca..694a94e78c7 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -15,7 +15,6 @@ import { useDeferredValue, useEffect, useMemo, - useRef, useState } from 'react' @@ -348,112 +347,6 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) ) } -// Steady character-reveal for streaming text: decouples visible cadence from -// bursty arrival so text flows instead of popping (cf. assistant-ui's useSmooth, -// reimplemented for a tunable rate). Proportional drain — each frame reveals a -// slice of the backlog so the reveal converges within ~REVEAL_DRAIN_MS whatever -// the size; the per-frame cap stops a huge dump rendering as one slab. The loop -// is gated on backlog, not isRunning, so a stream that completes mid-reveal -// keeps draining its tail instead of snapping. -const REVEAL_DRAIN_MS = 500 -const REVEAL_MAX_CHARS_PER_FRAME = 30 -// Floor between reveal commits. Each commit republishes the text context and -// re-runs the whole Streamdown pipeline (preprocess → remend → lex → micromark -// on the open block) over the full accumulated text — at raw rAF cadence -// that's 60 full parses/second and was the dominant streaming cost for -// reasoning text. ~33ms keeps the reveal visually fluid (2 frames) while -// halving the parse work. -const REVEAL_MIN_COMMIT_MS = 33 - -function useSmoothReveal(text: string, isRunning: boolean): string { - const [displayed, setDisplayed] = useState(isRunning ? '' : text) - const targetRef = useRef(text) - const shownRef = useRef(displayed) - const frameRef = useRef<number | null>(null) - const lastTickRef = useRef(0) - - shownRef.current = displayed - targetRef.current = text - - useEffect(() => { - if (typeof window === 'undefined') { - return - } - - // Non-extending change (regenerate / branch / history swap): restart from - // empty while streaming, else snap to the replacement. - if (!text.startsWith(shownRef.current)) { - shownRef.current = isRunning ? '' : text - setDisplayed(shownRef.current) - } - - if (shownRef.current.length >= text.length || frameRef.current !== null) { - return - } - - lastTickRef.current = performance.now() - - const tick = () => { - const now = performance.now() - const dt = now - lastTickRef.current - - // Skip this frame if the floor hasn't elapsed — the backlog math below - // is dt-proportional, so delayed commits reveal proportionally more. - if (dt < REVEAL_MIN_COMMIT_MS) { - frameRef.current = requestAnimationFrame(tick) - - return - } - - lastTickRef.current = now - - const remaining = targetRef.current.length - shownRef.current.length - - const add = Math.min( - remaining, - // dt-scaled so the per-commit cap stays equivalent to the old - // per-frame cap at any commit cadence. - Math.ceil((REVEAL_MAX_CHARS_PER_FRAME * dt) / 16.7), - Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS)) - ) - - shownRef.current = targetRef.current.slice(0, shownRef.current.length + add) - setDisplayed(shownRef.current) - - frameRef.current = shownRef.current.length < targetRef.current.length ? requestAnimationFrame(tick) : null - } - - frameRef.current = requestAnimationFrame(tick) - }, [text, isRunning]) - - useEffect( - () => () => { - if (frameRef.current !== null && typeof window !== 'undefined') { - cancelAnimationFrame(frameRef.current) - } - }, - [] - ) - - return displayed -} - -// Re-publish the part context with a smooth character-reveal, above -// DeferStreamingText so the reveal feeds the deferred markdown pipeline. Status -// stays running while revealing so the caret persists past the underlying part -// settling. -function SmoothStreamingText({ children }: { children: ReactNode }) { - const { text, status } = useMessagePartText() - const isRunning = status.type === 'running' - const revealed = useSmoothReveal(text, isRunning) - - return ( - <TextMessagePartProvider isRunning={isRunning || revealed !== text} text={revealed}> - {children} - </TextMessagePartProvider> - ) -} - /** * Re-publish the active message-part context with React's `useDeferredValue` * applied to the streaming text and status. The outer wrapper still re-renders @@ -694,13 +587,14 @@ interface MarkdownTextContentProps extends MarkdownTextSurfaceProps { } export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) { + // Same path as the assistant answer. A reasoning-only smoothing wrapper used + // to sit here but stalled its char-reveal at empty (the part stays running + // the whole message), blanking the Thinking widget. return ( <TextMessagePartProvider isRunning={isRunning} text={text}> - <SmoothStreamingText> - <DeferStreamingText> - <MarkdownTextSurface {...surfaceProps} /> - </DeferStreamingText> - </SmoothStreamingText> + <DeferStreamingText> + <MarkdownTextSurface {...surfaceProps} /> + </DeferStreamingText> </TextMessagePartProvider> ) } diff --git a/apps/desktop/src/components/assistant-ui/thread/block-direction.test.tsx b/apps/desktop/src/components/assistant-ui/thread/block-direction.test.tsx index cc63d4fbbe3..227f9e63b02 100644 --- a/apps/desktop/src/components/assistant-ui/thread/block-direction.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/block-direction.test.tsx @@ -25,6 +25,7 @@ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0) ) vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) +vi.stubGlobal('CSS', { escape: (str: string) => str }) Element.prototype.scrollTo = function scrollTo() {} diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index ccd672dac9f..0e1a2e7e088 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -1,4 +1,9 @@ -import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' +import { + type ReasoningMessagePartComponent, + type ToolCallMessagePartProps, + useAuiState, + useMessagePartReasoning +} from '@assistant-ui/react' import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useState } from 'react' import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' @@ -174,17 +179,19 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star ) } -const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => { - const displayText = text.trimStart() +// Read the part from context, same contract as MarkdownText's +// useMessagePartText — the reasoning-only smoothing wrapper (removed) stalled +// the char-reveal at empty, blanking the widget. +const ReasoningTextPart: ReasoningMessagePartComponent = () => { + const { status, text } = useMessagePartReasoning() const messageRunning = useAuiState(s => s.message.status?.type === 'running') - const isRunning = status?.type === 'running' || messageRunning return ( <MarkdownTextContent containerClassName="text-xs leading-snug text-muted-foreground/85" containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>} - isRunning={isRunning} - text={displayText} + isRunning={status.type === 'running' || messageRunning} + text={text.trimStart()} /> ) } diff --git a/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx index 3759f469e2f..fb844df0dc2 100644 --- a/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx @@ -48,6 +48,7 @@ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0) ) vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) +vi.stubGlobal('CSS', { escape: (str: string) => str }) Element.prototype.scrollTo = function scrollTo() {} diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx index 914fc043b12..3d1e7a69b80 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx @@ -28,6 +28,7 @@ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0) ) vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) +vi.stubGlobal('CSS', { escape: (str: string) => str }) Element.prototype.scrollTo = function scrollTo() {} diff --git a/apps/desktop/src/components/assistant-ui/tool/approval.test.tsx b/apps/desktop/src/components/assistant-ui/tool/approval.test.tsx index 2955fafe8d6..910a436f81f 100644 --- a/apps/desktop/src/components/assistant-ui/tool/approval.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/approval.test.tsx @@ -30,9 +30,13 @@ function part(toolName: string): ToolPart { return { toolName, type: `tool-${toolName}` } as unknown as ToolPart } -function setRequest(command = 'rm -rf /tmp/x', allowPermanent?: boolean) { +function setRequest( + command = 'rm -rf /tmp/x', + allowPermanent?: boolean, + extra: { choices?: string[]; smartDenied?: boolean } = {} +) { $activeSessionId.set('sess-1') - setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1' }) + setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1', ...extra }) } function mockGateway() { @@ -131,6 +135,26 @@ describe('PendingToolApproval', () => { expect(screen.queryByRole('menuitem', { name: /Always allow/ })).toBeNull() }) + it('renders only Once and Deny for a Smart DENY owner override', () => { + setRequest('rm -rf /tmp/x', true, { smartDenied: true }) + render(<PendingToolApproval part={part('terminal')} />) + + expect(screen.getByRole('button', { name: /Run/ })).toBeTruthy() + expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy() + expect(screen.queryByRole('button', { name: /More approval options/ })).toBeNull() + expect(screen.queryByText(/Allow this session/)).toBeNull() + expect(screen.queryByText(/Always allow/)).toBeNull() + }) + + it('renders only choices explicitly supplied by the gateway event', () => { + setRequest('rm -rf /tmp/x', true, { choices: ['once', 'deny'] }) + render(<PendingToolApproval part={part('terminal')} />) + + expect(screen.getByRole('button', { name: /Run/ })).toBeTruthy() + expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy() + expect(screen.queryByRole('button', { name: /More approval options/ })).toBeNull() + }) + it('renders a floating fallback when no pending tool row is mounted', () => { setRequest('rm /tmp/hermes_approval_test.txt') const { container } = render(<PendingApprovalFallback />) diff --git a/apps/desktop/src/components/assistant-ui/tool/approval.tsx b/apps/desktop/src/components/assistant-ui/tool/approval.tsx index 3416c22727b..9d0d69b06a1 100644 --- a/apps/desktop/src/components/assistant-ui/tool/approval.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/approval.tsx @@ -110,6 +110,10 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' const busy = submitting !== null // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow". const allowPermanent = request.allowPermanent !== false + const choices = request.choices ?? (request.smartDenied ? ['once', 'deny'] : undefined) + const allowSession = choices ? choices.includes('session') : true + const allowAlways = choices ? choices.includes('always') : allowPermanent + const hasMoreOptions = allowSession || allowAlways const hasCommand = request.command.trim().length > 0 const respond = useCallback( @@ -183,8 +187,8 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' {submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run} {submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>} </Button> - <span aria-hidden className="w-px self-stretch bg-primary/20" /> - <DropdownMenu> + {hasMoreOptions && <span aria-hidden className="w-px self-stretch bg-primary/20" />} + {hasMoreOptions && <DropdownMenu> <DropdownMenuTrigger asChild> <Button aria-label={copy.moreOptions} @@ -197,8 +201,8 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start" className="min-w-44"> - <DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem> - {allowPermanent && ( + {allowSession && <DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>} + {allowAlways && ( <DropdownMenuItem onSelect={() => { // Defer one tick so the menu fully unmounts before the dialog @@ -214,7 +218,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' {copy.reject} </DropdownMenuItem> </DropdownMenuContent> - </DropdownMenu> + </DropdownMenu>} </div> <Button diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts index 009abcfdf69..ea253ecadcb 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts @@ -89,7 +89,7 @@ describe('buildToolView browser_navigate title', () => { ) expect(view.status).toBe('error') - expect(view.title).toBe('Failed to open hermes-agent.nousresearch.com') + expect(view.title).toBe('Failed to open hermes-agent.nousresearch.com/docs') }) it('shows opened title on success', () => { @@ -103,7 +103,7 @@ describe('buildToolView browser_navigate title', () => { ) expect(view.status).toBe('success') - expect(view.title).toBe('Opened hermes-agent.nousresearch.com') + expect(view.title).toBe('Opened hermes-agent.nousresearch.com/docs') }) }) diff --git a/apps/desktop/src/components/boot-failure-overlay.test.tsx b/apps/desktop/src/components/boot-failure-overlay.test.tsx new file mode 100644 index 00000000000..e86f987a631 --- /dev/null +++ b/apps/desktop/src/components/boot-failure-overlay.test.tsx @@ -0,0 +1,101 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { $desktopBoot } from '@/store/boot' +import { $desktopOnboarding } from '@/store/onboarding' + +import { BootFailureOverlay } from './boot-failure-overlay' + +// Remote-backend users hit a hard boot failure that isn't OAuth reauth (token +// auth, wrong URL, unreachable host). The recovery screen must let them fix the +// remote connection in place — the "Connection settings" action swaps the card +// to an in-line connect form — instead of stranding them (the old bug forced a +// hand-edit of connection.json). + +function failBoot() { + $desktopBoot.set({ + error: 'Could not connect to Hermes gateway', + fakeMode: false, + message: 'boot failed', + phase: 'renderer.error', + progress: 40, + running: false, + timestamp: Date.now(), + visible: true + }) +} + +function stubDesktop(config: Record<string, unknown>) { + const original = window.hermesDesktop + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { getRecentLogs: async () => ({ lines: [] }), getConnectionConfig: async () => config } + }) + + return () => Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: original }) +} + +const remoteToken = { + envOverride: false, + mode: 'remote', + profile: null, + remoteAuthMode: 'token', + remoteOauthConnected: false, + remoteTokenPreview: null, + remoteTokenSet: true, + remoteUrl: 'http://100.116.104.53:9191', + cloudOrg: '' +} + +beforeEach(() => { + $desktopOnboarding.set({ + configured: true, + flow: { status: 'idle' }, + mode: 'oauth', + providers: null, + reason: null, + requested: false, + firstRunSkipped: false, + manual: false, + localEndpoint: false + }) + failBoot() +}) + +afterEach(cleanup) + +describe('BootFailureOverlay', () => { + it('swaps to the in-place gateway settings view (no route nav) and back', async () => { + render(<BootFailureOverlay />) + + fireEvent.click(screen.getByRole('button', { name: /gateway settings/i })) + // Recovery actions give way to the embedded panel (behind a Back control). + expect(await screen.findByRole('button', { name: /back/i })).toBeTruthy() + expect(screen.queryByRole('button', { name: /retry/i })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /back/i })) + expect(screen.getByRole('button', { name: /retry/i })).toBeTruthy() + expect(screen.queryByRole('button', { name: /back/i })).toBeNull() + }) + + it('drops local-only Repair and Use-local-gateway on a local failure', () => { + render(<BootFailureOverlay />) + // No connection config stub → treated as a local failure. + expect(screen.getByRole('button', { name: /retry/i })).toBeTruthy() + expect(screen.getByRole('button', { name: /repair/i })).toBeTruthy() + expect(screen.queryByRole('button', { name: /use local gateway/i })).toBeNull() + }) + + it('leads with Gateway settings and drops Repair for a remote (token) failure', async () => { + const restore = stubDesktop(remoteToken) + + try { + render(<BootFailureOverlay />) + await waitFor(() => expect(screen.queryByRole('button', { name: /repair/i })).toBeNull()) + expect(screen.getByRole('button', { name: /gateway settings/i })).toBeTruthy() + expect(screen.getByRole('button', { name: /use local gateway/i })).toBeTruthy() + } finally { + restore() + } + }) +}) diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index bb17f79c3cd..381bd94efec 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -1,20 +1,29 @@ import { useStore } from '@nanostores/react' -import { useEffect, useState } from 'react' +import { type ComponentProps, lazy, type ReactNode, Suspense, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { ErrorIcon } from '@/components/ui/error-state' +import { Loader } from '@/components/ui/loader' import { LogView } from '@/components/ui/log-view' import type { DesktopConnectionConfig } from '@/global' import { useI18n } from '@/i18n' -import { FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons' +import { ChevronLeft, FileText, Loader2, LogIn, RefreshCw, SlidersHorizontal, Wrench } from '@/lib/icons' import { $desktopBoot } from '@/store/boot' import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding } from '@/store/onboarding' import type { RemoteReauth } from './boot-failure-reauth' -import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth' +import { deriveProviderShape, isRemoteConfig, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth' + +// The recovery "Gateway settings" view embeds the real Settings → Gateway panel +// (identical URL/auth/test/save controls — no parallel form to drift). Lazy so +// it stays out of the always-mounted overlay's bundle until opened. +const GatewaySettings = lazy(() => + import('@/app/settings/gateway-settings').then(module => ({ default: module.GatewaySettings })) +) type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null +type RecoveryView = 'connect' | 'recovery' // A remote gateway whose access cookie has lapsed (e.g. the dashboard // restarted on the remote box) boots into this overlay with a reauth-shaped @@ -35,6 +44,13 @@ export function BootFailureOverlay() { const [logs, setLogs] = useState<string[]>([]) const [showLogs, setShowLogs] = useState(false) const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null) + // A remote/cloud backend that failed to boot is fixable from gateway settings, + // so the escape hatch earns emphasis (local failures keep it as a quiet ghost). + const [remoteFailure, setRemoteFailure] = useState(false) + // Swap the card body to the embedded Gateway settings panel in place of routing + // to the full Settings page (keeps the user on the recovery surface, no z-index + // juggling, no second connection form to maintain). + const [view, setView] = useState<RecoveryView>('recovery') const visible = Boolean(boot.error) && !boot.running // While first-run onboarding owns the picker/flow we let it surface its own @@ -51,7 +67,7 @@ export function BootFailureOverlay() { ?.getRecentLogs() .then(res => setLogs(res.lines ?? [])) .catch(() => undefined) - }, [visible]) + }, [boot.error, visible]) // Resolve whether this boot failure is a remote-gateway reauth so we can // offer the actionable "Sign in" path instead of the local-only recovery @@ -59,6 +75,8 @@ export function BootFailureOverlay() { useEffect(() => { if (!visible) { setRemoteReauth(null) + setRemoteFailure(false) + setView('recovery') return } @@ -80,7 +98,13 @@ export function BootFailureOverlay() { return } - if (cancelled || !isRemoteReauthFailure(config)) { + if (cancelled) { + return + } + + setRemoteFailure(isRemoteConfig(config)) + + if (!isRemoteReauthFailure(config, boot.error)) { return } @@ -104,7 +128,7 @@ export function BootFailureOverlay() { return () => { cancelled = true } - }, [visible]) + }, [boot.error, visible]) if (!visible || suppressed) { return null @@ -124,16 +148,17 @@ export function BootFailureOverlay() { const switchToLocalGateway = async () => { setBusy('local') - // applyConnectionConfig reloads the window from the main process. + // Soft apply: tears down the primary and re-dials in place (shell stays). await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' }).catch(() => undefined) setBusy(null) } - // Open the gateway's login window (renders the username/password form for a - // basic gateway, or the OAuth redirect otherwise — the desktop drives both - // through the same window). On a successful sign-in the session cookie is - // re-established in the persistent partition; reload so boot re-runs and the - // reconnect now mints a ticket against a live session. + // Clear the OAuth partition first, then open the gateway's login window + // (username/password form or OAuth redirect — the desktop drives both). A + // partition-wide sign-out drops stale gateway AND identity-provider cookies so + // an expired session can't silently bounce us back into the same state. On a + // successful sign-in the cookie is re-established; reload so boot mints a fresh + // ticket against a live session. const signInRemote = async () => { if (!remoteReauth) { return @@ -142,6 +167,7 @@ export function BootFailureOverlay() { setBusy('signin') try { + await window.hermesDesktop?.oauthLogoutConnectionConfig?.() const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url) if (result?.connected) { @@ -172,6 +198,92 @@ export function BootFailureOverlay() { withProvider: copy.signInWithProvider }) + // Recovery actions are shaped by the failure kind so the leading (primary) + // button is the one that actually fixes it: Sign in for a lapsed remote + // session, Connection settings for any other remote failure (local Retry / + // Repair can't revive a dead remote — Repair is dropped there), Retry for a + // local backend. Open logs is always appended. + type RecoveryVariant = ComponentProps<typeof Button>['variant'] + interface RecoveryAction { + key: string + label: string + onClick: () => void + icon?: ReactNode + variant?: RecoveryVariant + busy?: Exclude<BusyAction, null> + } + + const settingsAction: RecoveryAction = { + key: 'settings', + label: copy.gatewaySettings, + onClick: () => setView('connect'), + icon: <SlidersHorizontal /> + } + + const retryAction: RecoveryAction = { + key: 'retry', + label: copy.retry, + onClick: () => void retry(), + icon: <RefreshCw />, + busy: 'retry' + } + + const localAction: RecoveryAction = { + key: 'local', + label: copy.useLocalGateway, + onClick: () => void switchToLocalGateway(), + variant: 'secondary', + busy: 'local' + } + + let actions: RecoveryAction[] + let hint: string + + if (remoteReauth) { + actions = [ + { key: 'signin', label: copy.signOutAndSignIn, onClick: () => void signInRemote(), icon: <LogIn />, busy: 'signin' }, + { ...settingsAction, variant: 'secondary' }, + localAction + ] + hint = copy.remoteSignInHint(label) + } else if (remoteFailure) { + actions = [settingsAction, { ...retryAction, variant: 'secondary' }, localAction] + hint = copy.remoteFailureHint + } else { + // Local failure: Use-local is redundant with Retry (both re-target local), so + // it's dropped here; keep it for remote failures where it's the fall-back. + actions = [ + retryAction, + { key: 'repair', label: copy.repairInstall, onClick: () => void repair(), icon: <Wrench />, variant: 'secondary', busy: 'repair' }, + { ...settingsAction, variant: 'ghost' } + ] + hint = copy.repairHint + } + + if (view === 'connect') { + return ( + <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6"> + <div className="flex max-h-[86vh] w-full max-w-[46rem] flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous"> + {/* Subtle back affordance (projects/overlay idiom): muted → foreground + on hover, no divider. */} + <button + className="flex w-full items-center gap-1.5 px-4 pt-4 text-left text-xs text-muted-foreground transition-colors hover:text-foreground" + onClick={() => setView('recovery')} + type="button" + > + <ChevronLeft className="size-3.5" /> + {copy.back} + </button> + <div className="min-h-0 flex-1 pt-4"> + <Suspense fallback={<Loader className="mx-auto my-16 size-6 text-(--ui-text-tertiary)" />}> + <GatewaySettings embedded /> + </Suspense> + </div> + </div> + </div> + ) + } + return ( <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6"> <div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous"> @@ -187,40 +299,25 @@ export function BootFailureOverlay() { </div> </div> - <div className="grid gap-4 p-5"> + <div className="grid gap-4 p-5 pt-0"> <div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive"> {boot.error} </div> <div className="grid gap-2"> <div className="flex flex-wrap gap-2"> - {remoteReauth ? ( - <Button disabled={Boolean(busy)} onClick={() => void signInRemote()}> - {busy === 'signin' ? <Loader2 className="animate-spin" /> : <LogIn />} - {label} + {actions.map(action => ( + <Button disabled={Boolean(busy)} key={action.key} onClick={action.onClick} variant={action.variant}> + {action.busy && busy === action.busy ? <Loader2 className="animate-spin" /> : action.icon} + {action.label} </Button> - ) : ( - <Button disabled={Boolean(busy)} onClick={() => void retry()}> - {busy === 'retry' ? <Loader2 className="animate-spin" /> : <RefreshCw />} - {copy.retry} - </Button> - )} - {!remoteReauth ? ( - <Button disabled={Boolean(busy)} onClick={() => void repair()} variant="secondary"> - {busy === 'repair' ? <Loader2 className="animate-spin" /> : <Wrench />} - {copy.repairInstall} - </Button> - ) : null} - <Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary"> - {busy === 'local' ? <Loader2 className="animate-spin" /> : null} - {copy.useLocalGateway} - </Button> + ))} <Button onClick={openLogs} variant="ghost"> <FileText /> {copy.openLogs} </Button> </div> - <p className="text-xs text-muted-foreground">{remoteReauth ? copy.remoteSignInHint : copy.repairHint}</p> + <p className="text-xs text-muted-foreground">{hint}</p> </div> {logs.length > 0 ? ( diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts index 613b43f6535..5d198c96e41 100644 --- a/apps/desktop/src/components/boot-failure-reauth.test.ts +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest' import type { DesktopConnectionConfig } from '@/global' -import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth' +import { + deriveProviderShape, + isRemoteConfig, + isRemoteReauthError, + isRemoteReauthFailure, + signInLabel +} from './boot-failure-reauth' function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnectionConfig { return { @@ -14,23 +20,53 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec remoteTokenPreview: null, remoteTokenSet: false, remoteUrl: 'https://box:9119', + cloudOrg: '', ...overrides } } +describe('isRemoteConfig', () => { + it('true for remote/cloud with a URL, regardless of auth mode or connection', () => { + expect(isRemoteConfig(config({ remoteAuthMode: 'token', remoteOauthConnected: false }))).toBe(true) + expect(isRemoteConfig(config({ mode: 'cloud', remoteOauthConnected: true }))).toBe(true) + }) + + it('false for local, for a remote with no URL, and for nullish', () => { + expect(isRemoteConfig(config({ mode: 'local' }))).toBe(false) + expect(isRemoteConfig(config({ remoteUrl: '' }))).toBe(false) + expect(isRemoteConfig(null)).toBe(false) + }) +}) + describe('isRemoteReauthFailure', () => { it('true for a remote, gated, disconnected gateway with a URL', () => { expect(isRemoteReauthFailure(config())).toBe(true) }) - it('false when the oauth session is still connected', () => { - expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }))).toBe(false) + it('false when connected and the boot error is not auth-shaped', () => { + expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }), 'Python exploded')).toBe(false) + }) + + it('true when the indicator reads connected but the boot error is auth-shaped (expired session)', () => { + expect( + isRemoteReauthFailure(config({ remoteOauthConnected: true }), 'Your remote gateway session has expired.') + ).toBe(true) }) it('false for a local gateway', () => { expect(isRemoteReauthFailure(config({ mode: 'local' }))).toBe(false) }) + it('true for a cloud connection with a lapsed session (cloud resolves to remote oauth)', () => { + // A 'cloud' connection is a remote oauth backend under the hood (Q6), so a + // lapsed cloud session is the same reauth failure as a lapsed remote one. + expect(isRemoteReauthFailure(config({ mode: 'cloud' }))).toBe(true) + }) + + it('false for a connected cloud session', () => { + expect(isRemoteReauthFailure(config({ mode: 'cloud', remoteOauthConnected: true }))).toBe(false) + }) + it('false for a token (non-gated) remote gateway', () => { expect(isRemoteReauthFailure(config({ remoteAuthMode: 'token' }))).toBe(false) }) @@ -45,6 +81,18 @@ describe('isRemoteReauthFailure', () => { }) }) +describe('isRemoteReauthError', () => { + it('recognizes auth-shaped boot errors', () => { + expect(isRemoteReauthError('Your remote gateway session has expired.')).toBe(true) + expect(isRemoteReauthError('OAuth: please sign in')).toBe(true) + }) + + it('ignores non-auth boot errors and nullish', () => { + expect(isRemoteReauthError('Hermes background process exited during startup.')).toBe(false) + expect(isRemoteReauthError(null)).toBe(false) + }) +}) + describe('deriveProviderShape', () => { it('generic copy when there are no providers', () => { expect(deriveProviderShape([])).toEqual({ isPassword: false, providerLabel: 'your identity provider' }) diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts index 3aeae7846e4..63b805faccb 100644 --- a/apps/desktop/src/components/boot-failure-reauth.ts +++ b/apps/desktop/src/components/boot-failure-reauth.ts @@ -26,22 +26,40 @@ const DEFAULT_SIGN_IN_COPY: SignInCopy = { withProvider: provider => `Sign in with ${provider}` } -// A remote, gated (oauth-bucket), not-currently-connected gateway is a -// remote-reauth boot failure: the access cookie lapsed (e.g. the remote -// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't -// fix it — only re-establishing the remote session can. A connected oauth -// session, or a token/local gateway, boots for some other reason the -// local-recovery buttons address, so those return false here. -export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean { - if (!config) { - return false - } +// True when the app is pointed at a remote/cloud backend (either resolves to a +// remote URL). Any boot failure in this shape is fixable from Settings → +// Gateway (edit URL / token / sign in) — the local Retry/Repair buttons target +// the bundled backend and can't help. Drives the escape-hatch emphasis. +export function isRemoteConfig(config: DesktopConnectionConfig | null | undefined): boolean { + return Boolean(config && (config.mode === 'remote' || config.mode === 'cloud') && config.remoteUrl) +} + +// True when a boot error is auth-shaped — the refresh token was rejected or the +// remote couldn't mint a websocket ticket. The Settings indicator can still read +// "connected" (a stale RT cookie exists), so the error text is part of the +// signal; without it a connected-but-expired session drops into the local-only +// recovery buttons for a problem only reauth can fix. +export function isRemoteReauthError(error: string | null | undefined): boolean { + const text = String(error || '').toLowerCase() return ( - config.mode === 'remote' && - config.remoteAuthMode === 'oauth' && - !config.remoteOauthConnected && - Boolean(config.remoteUrl) + text.includes('remote gateway session has expired') || + text.includes('gateway sign-in required') || + text.includes('needs oauth login') || + (text.includes('oauth') && (text.includes('not signed in') || text.includes('sign in'))) + ) +} + +// A remote, gated (oauth-bucket) gateway is a remote-reauth boot failure when the +// session isn't connected OR the boot error is auth-shaped (connected-but-expired +// — see isRemoteReauthError). Only re-establishing the remote session fixes it; +// the local Retry/Repair buttons can't. 'cloud' counts as remote (it resolves to +// a remote oauth backend), so a lapsed cloud session is the same failure. +export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined, error?: string | null): boolean { + return ( + isRemoteConfig(config) && + config!.remoteAuthMode === 'oauth' && + (!config!.remoteOauthConnected || isRemoteReauthError(error)) ) } diff --git a/apps/desktop/src/components/chat/vibe-hearts.tsx b/apps/desktop/src/components/chat/vibe-hearts.tsx new file mode 100644 index 00000000000..b4f5e7e1a24 --- /dev/null +++ b/apps/desktop/src/components/chat/vibe-hearts.tsx @@ -0,0 +1,119 @@ +import { type CSSProperties } from 'react' + +import { + createParticleEmitter, + ParticleField, + type ParticleFieldConfig +} from '@/components/particles/particle-field' +import { $petActive, flashPetActivity } from '@/store/pet' +import { $petOverlayActive, forwardPetReaction } from '@/store/pet-overlay' + +/** + * TikTok-style floating hearts — a thin skin over {@link ParticleField} (pixel + * heart glyph + pink). Placed two ways: rising from the composer when no pet is + * out, or from the pet when one is. Fired by the core `reaction` event (affection + * in a user message) via {@link burstVibeHearts}. + */ + +// Light pink reads on both light and dark chat surfaces. +const HEART_COLORS = ['#ff9ec4'] as const + +/** Composer placement: hearts rise the thread height (rise = % of the tall lane). */ +export const COMPOSER_HEART_CONFIG: Partial<ParticleFieldConfig> = { + count: 12, + size: [6, 13], + rise: [6.75, 15.75], + duration: [320, 700] +} + +/** Pet placement: a compact puff off the pet. The field box spans feet→head, so + * rise ≥100% carries hearts from the feet to ~10-20% above the pet before fading. */ +const PET_HEART_CONFIG: Partial<ParticleFieldConfig> = { + count: 10, + spawnWindowMs: 450, + size: [6, 12], + rise: [98, 118], + duration: [480, 880], + swayAmp: [5, 14], + bank: [6, 14] +} + +// Pixel-art heart from @nous-research/ui (14×12), crisp + `currentColor`. +const HEART_GLYPH = ( + <svg fill="none" shapeRendering="crispEdges" viewBox="0 0 14 12" xmlns="http://www.w3.org/2000/svg"> + <path + d="M13.2 0v5.65714h-1.8857v1.88572H9.42857v1.88571H7.54286v1.88573H5.65714V9.42857H3.77143V7.54286H1.88571V5.65714H0V0h5.65714v1.88571h1.88572V0z" + fill="currentColor" + /> + </svg> +) + +const emitter = createParticleEmitter() + +/** Play hearts in THIS window (whichever HeartField is mounted). The overlay + * window calls this directly off the mirrored vibe signal. */ +export const playVibeHearts = (count?: number) => emitter.burst(count) + +/** + * Fire a vibe burst (from the core `reaction` event). Routes to where the + * affection should land: + * - pet popped out → forward to the overlay window + celebrate (mirrored) + * - pet in-window → play here (on the pet) + celebrate + * - no pet → play here (composer) + */ +export const burstVibeHearts = (count?: number) => { + const overlay = $petOverlayActive.get() + + if (overlay || $petActive.get()) { + flashPetActivity({ celebrate: true }) + } + + if (overlay) { + forwardPetReaction('vibe') + } else { + playVibeHearts(count) + } +} + +export interface HeartFieldProps { + config?: Partial<ParticleFieldConfig> + className?: string + style?: CSSProperties +} + +/** Heart-skinned particle field. Caller supplies placement + a config preset. */ +export function HeartField({ config, className, style }: HeartFieldProps) { + return ( + <ParticleField + className={className} + colors={HEART_COLORS} + config={config} + emitter={emitter} + glyph={HEART_GLYPH} + style={style} + /> + ) +} + +/** + * Pet-anchored hearts, feet→~10-20% above. One place owns the geometry so the + * in-window pet and the popped-out overlay stay identical. `petW`/`petH` are the + * rendered sprite dimensions (frame × scale). + */ +export function PetHeartField({ petW, petH }: { petW: number; petH: number }) { + return ( + <HeartField + config={PET_HEART_CONFIG} + style={{ + bottom: 0, + height: Math.max(96, petH), + left: '50%', + pointerEvents: 'none', + position: 'absolute', + transform: 'translateX(-50%)', + width: Math.max(90, petW * 1.5), + zIndex: 2 + }} + /> + ) +} diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 7bcbc4bf84b..f15bd79cdc2 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -22,7 +22,7 @@ import { cn } from '@/lib/utils' * DesktopInstallOverlay * * Renders the first-launch install progress for Hermes Agent. Mounted always; - * shows itself only when main.cjs reports an in-flight bootstrap (state.active) + * shows itself only when main.ts reports an in-flight bootstrap (state.active) * OR an error from a completed-failed bootstrap (state.error). When the * bootstrap finishes successfully the overlay fades out and the rest of the * app (existing onboarding overlay -> main UI) takes over. @@ -32,7 +32,7 @@ import { cn } from '@/lib/utils' * - onBootstrapEvent(callback) -- live event stream * * The reducer is intentionally simple: every event mutates an in-component - * snapshot the same way main.cjs mutates its server-side snapshot. We don't + * snapshot the same way main.ts mutates its server-side snapshot. We don't * try to reconcile -- if we miss an event (shouldn't happen) the initial * getBootstrapState() call will resync the picture on the next render. * @@ -559,7 +559,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP </Button> <Button onClick={async () => { - // Tell main.cjs to clear its latched failure BEFORE we + // Tell main.ts to clear its latched failure BEFORE we // reload. Otherwise the renderer reload calls getConnection // and main short-circuits to the latched error without // re-running install.ps1. diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx index e5e49315985..508dfb27f33 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx @@ -1,7 +1,8 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { $desktopBoot } from '@/store/boot' +import { $gatewaySwitching } from '@/store/gateway-switch' import { $desktopOnboarding } from '@/store/onboarding' import { setGatewayState } from '@/store/session' @@ -23,6 +24,7 @@ import { GatewayConnectingOverlay } from './gateway-connecting-overlay' function resetStores() { setGatewayState('idle') + $gatewaySwitching.set(false) $desktopBoot.set({ error: null, fakeMode: false, @@ -59,7 +61,7 @@ const isRecoveryShown = () => Boolean(screen.queryByText(/use local gateway/i) || screen.queryByText(/retry/i) || screen.queryByText(/sign in/i)) describe('connecting overlay vs recovery surface', () => { - it('hard initial-boot failure surfaces the recovery overlay (the working path)', () => { + it('hard initial-boot failure surfaces the recovery overlay (the working path)', async () => { // failDesktopBoot() ran: error set, gateway never opened. $desktopBoot.set({ ...$desktopBoot.get(), @@ -69,41 +71,50 @@ describe('connecting overlay vs recovery surface', () => { }) setGatewayState('error') - render( - <> - <GatewayConnectingOverlay /> - <BootFailureOverlay /> - </> - ) + await act(async () => { + render( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + }) expect(isRecoveryShown()).toBe(true) // Connecting overlay bows out when boot.error is set. expect(isConnectingShown()).toBe(false) }) - it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', () => { + it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', async () => { // 1. Initial boot succeeded: gateway opened, boot completed (no error). setGatewayState('open') - const { rerender } = render( - <> - <GatewayConnectingOverlay /> - <BootFailureOverlay /> - </> - ) + let rerender!: (ui: React.ReactElement) => void + await act(async () => { + const result = render( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + + rerender = result.rerender + }) expect(isConnectingShown()).toBe(false) // 2. The remote VPS socket drops (sleep/wake, remote restart, network). // bootCompleted is true, so useGatewayBoot routes this through // scheduleReconnect() — boot.error stays NULL. - setGatewayState('closed') - rerender( - <> - <GatewayConnectingOverlay /> - <BootFailureOverlay /> - </> - ) + await act(async () => { + setGatewayState('closed') + rerender!( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + }) // The initial-boot connecting overlay stays out of the way, so settings and // the composer remain reachable during the reconnect loop. @@ -113,19 +124,53 @@ describe('connecting overlay vs recovery surface', () => { // 3. Reconnect loops against the dead remote: gatewayState bounces closed // → error → closed. Until the escalation path sets boot.error, the app // remains usable instead of modal-blocked. - setGatewayState('error') - rerender( - <> - <GatewayConnectingOverlay /> - <BootFailureOverlay /> - </> - ) + await act(async () => { + setGatewayState('error') + rerender!( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + }) expect($desktopBoot.get().error).toBeNull() expect(isConnectingShown()).toBe(false) expect(isRecoveryShown()).toBe(false) }) - it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', () => { + it('soft gateway switch keeps the shell — no fullscreen CONNECTING', async () => { + setGatewayState('open') + + const { rerender } = render( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + + await act(async () => { + $gatewaySwitching.set(true) + $desktopBoot.set({ + ...$desktopBoot.get(), + running: true, + visible: true, + progress: 4, + error: null + }) + setGatewayState('closed') + rerender( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + }) + + expect(isConnectingShown()).toBe(false) + expect(isRecoveryShown()).toBe(false) + }) + + it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', async () => { // Mirrors what useGatewayBoot.scheduleReconnect() now does after ~45s of // failed post-boot reconnects: it calls failDesktopBoot(), flipping the UI // from the dead-end CONNECTING overlay to the recovery surface. @@ -137,16 +182,18 @@ describe('connecting overlay vs recovery surface', () => { visible: true }) - render( - <> - <GatewayConnectingOverlay /> - <BootFailureOverlay /> - </> - ) + await act(async () => { + render( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + }) // Escape hatch is now reachable; the connecting overlay bows out. expect(isRecoveryShown()).toBe(true) - expect(screen.getByText(/use local gateway/i)).toBeTruthy() + expect(screen.getByRole('button', { name: /gateway settings/i })).toBeTruthy() expect(isConnectingShown()).toBe(false) }) }) diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index bff722b9a28..aa206514750 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react' import { cn } from '@/lib/utils' import { $desktopBoot } from '@/store/boot' +import { $gatewaySwitching } from '@/store/gateway-switch' import { $gatewayState } from '@/store/session' // Static, always-legible prefix; only TAIL ever scrambles. Splitting them at @@ -48,9 +49,17 @@ function scrambledTail(resolvedCount: number): string { export function GatewayConnectingOverlay() { const gatewayState = useStore($gatewayState) const boot = useStore($desktopBoot) + const gatewaySwitching = useStore($gatewaySwitching) const [previewing] = useState(forcedPreview) const [tail, setTail] = useState(TAIL) const [phase, setPhase] = useState<Phase>('live') + // Once cold boot has completed once, never resurrect the fullscreen overlay + // — soft gateway switches keep the shell and reskeleton the sidebar instead. + const coldBootDoneRef = useRef(false) + + if (!boot.running && boot.progress >= 100 && !boot.error) { + coldBootDoneRef.current = true + } // The full-screen connecting overlay is for initial boot only. After a // healthy boot, flaky networks / sleep-wake can drop the socket and flip the @@ -58,7 +67,12 @@ export function GatewayConnectingOverlay() { // the chat then — users should still be able to type drafts, open settings, // and recover instead of staring at a modal CONNECTING screen. const initialBootActive = boot.visible || boot.running || boot.progress < 100 - const connecting = gatewayState !== 'open' && !boot.error && initialBootActive + const connecting = + !coldBootDoneRef.current && + !gatewaySwitching && + gatewayState !== 'open' && + !boot.error && + initialBootActive // Latches once we've actually shown the overlay, so the brief frame where // gatewayState flips to "open" (connecting -> false) before the exit phase // kicks in doesn't unmount us and cause a flash. diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index b344448925e..68957138442 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -32,7 +32,13 @@ import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes' import { DocsLink, FlowPanel, Status } from './flow' import { DetectedLocalServerRow, FeaturedProviderRow, KeyProviderRow, ProviderRow, sortProviders } from './providers' -export { FeaturedProviderRow, KeyProviderRow, ProviderRow, providerTitle, sortProviders } from './providers' +export { + FeaturedProviderRow, + KeyProviderRow, + ProviderRow, + providerTitle, + sortProviders +} from './providers' interface DesktopOnboardingOverlayProps { enabled: boolean @@ -57,6 +63,12 @@ const API_KEY_OPTIONS: ApiKeyOption[] = [ envKey: 'OPENROUTER_API_KEY', docsUrl: 'https://openrouter.ai/keys' }, + { + id: 'fireworks', + name: 'Fireworks AI', + envKey: 'FIREWORKS_API_KEY', + docsUrl: 'https://app.fireworks.ai/settings/users/api-keys' + }, { id: 'openai', name: 'OpenAI', @@ -397,6 +409,13 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { const { t } = useI18n() const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding) const [showAll, setShowAll] = useState(readShowAll) + // Which key-form option to preselect when we flip to 'apikey' mode. The + // OpenRouter row selects its key; the generic link lands on the first option. + const [apiKeyInitialEnv, setApiKeyInitialEnv] = useState<string | undefined>(undefined) + const openKeyForm = (envKey?: string) => { + setApiKeyInitialEnv(envKey) + setOnboardingMode('apikey') + } const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers]) const hasOauth = ordered.length > 0 const apiKeyOptions = useApiKeyCatalog() @@ -426,7 +445,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { <div className="grid gap-3"> <ApiKeyForm canGoBack={hasOauth && !localEndpoint} - initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : undefined} + initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : apiKeyInitialEnv} onBack={() => setOnboardingMode('oauth')} onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)} options={apiKeyOptions} @@ -467,7 +486,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { {rest.map(p => ( <ProviderRow key={p.id} onSelect={select} provider={p} /> ))} - <KeyProviderRow onClick={() => setOnboardingMode('apikey')} /> + <KeyProviderRow onClick={() => openKeyForm('OPENROUTER_API_KEY')} /> </> ) : null} </div> @@ -490,7 +509,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { {manual ? <span /> : <ChooseLaterLink />} <Button className="-mr-2 font-medium" - onClick={() => setOnboardingMode('apikey')} + onClick={() => openKeyForm()} size="xs" type="button" variant="text" diff --git a/apps/desktop/src/components/pane-shell/pane-shell.test.tsx b/apps/desktop/src/components/pane-shell/pane-shell.test.tsx index 99f481f0540..0517b08c8ac 100644 --- a/apps/desktop/src/components/pane-shell/pane-shell.test.tsx +++ b/apps/desktop/src/components/pane-shell/pane-shell.test.tsx @@ -153,7 +153,7 @@ describe('PaneShell composition', () => { const rendered = render( <PaneShell> - <Pane id="files" side="left" width="240px"> + <Pane id="files" resizable side="left" width="240px"> files </Pane> <PaneMain>main</PaneMain> diff --git a/apps/desktop/src/components/particles/particle-field.css b/apps/desktop/src/components/particles/particle-field.css new file mode 100644 index 00000000000..94bf2258aae --- /dev/null +++ b/apps/desktop/src/components/particles/particle-field.css @@ -0,0 +1,126 @@ +/* ── Particle field ───────────────────────────────────────────────────────── + Reusable float-up emitter. Three nested layers, each owning ONE transform so + they never fight: + .particle — full-height track: vertical rise + fade (linear) + .particle__sway — horizontal weave + bank/tilt on its OWN period + (ease-in-out, alternating), so the path desyncs from + the rise and never repeats — the organic-motion trick + .particle__glyph — one-shot springy pop-in scale + The track is full-height and anchored at the field bottom, so the rise's + `translateY(-rise%)` measures against the field height, not the glyph's. All + tuning arrives as inline `--particle-*` custom properties. */ +.particle-field { + pointer-events: none; + overflow: visible; +} + +.particle { + position: absolute; + inset-block: 0; + left: var(--particle-left); + display: flex; + align-items: flex-end; + justify-content: center; + width: var(--particle-size); + margin-left: calc(var(--particle-size) / -2); + will-change: transform, opacity; + animation: particle-rise var(--particle-duration) linear var(--particle-delay) both; +} + +.particle__sway { + display: block; + will-change: transform; + animation: particle-sway var(--particle-sway-duration) ease-in-out var(--particle-sway-delay) + infinite alternate; +} + +.particle__glyph { + display: block; + width: var(--particle-size); + color: var(--particle-color); + will-change: transform; + animation: particle-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) var(--particle-delay) both; +} + +.particle__glyph > svg { + display: block; + width: 100%; + height: auto; +} + +/* Rise: straight up the field, holds opacity through most of the climb, fades + near the top. */ +@keyframes particle-rise { + 0% { + opacity: 0; + transform: translate3d(0, 0, 0); + } + + 6% { + opacity: 1; + transform: translate3d(0, calc(var(--particle-rise) * -0.06%), 0); + } + + 70% { + opacity: 1; + transform: translate3d(0, calc(var(--particle-rise) * -0.7%), 0); + } + + 100% { + opacity: 0; + transform: translate3d(0, calc(var(--particle-rise) * -1%), 0); + } +} + +/* Sway + bank: swings left↔right and tilts INTO the direction of travel, like a + leaf on a breeze. Runs on its own clock. */ +@keyframes particle-sway { + from { + transform: translateX(calc(var(--particle-sway) * -1)) rotate(calc(var(--particle-bank) * -1)); + } + + to { + transform: translateX(var(--particle-sway)) rotate(var(--particle-bank)); + } +} + +/* Pop: a springy scale-in overshoot (the bezier is a spring, not a path wobble). */ +@keyframes particle-pop { + 0% { + transform: scale(0.3); + } + + 100% { + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .particle { + inset-block: auto 0; + height: var(--particle-size); + animation: particle-flash 650ms ease-out var(--particle-delay) both; + } + + .particle__sway, + .particle__glyph { + animation: none; + } + + @keyframes particle-flash { + 0% { + opacity: 0; + transform: translate3d(0, 0, 0) scale(0.6); + } + + 20% { + opacity: 1; + transform: translate3d(0, -0.5rem, 0) scale(1.1); + } + + 100% { + opacity: 0; + transform: translate3d(0, -1rem, 0) scale(1); + } + } +} diff --git a/apps/desktop/src/components/particles/particle-field.tsx b/apps/desktop/src/components/particles/particle-field.tsx new file mode 100644 index 00000000000..a7e70c933c6 --- /dev/null +++ b/apps/desktop/src/components/particles/particle-field.tsx @@ -0,0 +1,204 @@ +import './particle-field.css' + +import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' + +import { cn } from '@/lib/utils' + +/** + * Reusable float-up particle emitter. It owns the motion (rise + organic sway + + * springy pop) and lifecycle (staggered burst, lifetime, cleanup); callers just + * hand it a `glyph` (any element using `currentColor`) and `colors`, then place + * it with `className` / `style`. See {@link VibeHearts} for the chat-hearts use. + */ + +type Range = readonly [min: number, max: number] + +const rand = ([min, max]: Range) => min + Math.random() * (max - min) +/** Sample a range along `t` (0→min, 1→max) — couples travel/lifetime to `life`. */ +const lerp = ([min, max]: Range, t: number) => min + (max - min) * t + +export interface ParticleFieldConfig { + /** Particles per burst when `burst()` is called without a count. */ + count: number + /** Window (ms) over which a burst releases its particles — small = one poof. */ + spawnWindowMs: number + /** Glyph edge size (px), uniform. */ + size: Range + /** Vertical travel before fade-out (% of field height), `life`-biased. */ + rise: Range + /** Rise duration (ms), `life`-biased so short-lived particles also rise less. */ + duration: Range + /** Sway amplitude each side of center (px), uniform. */ + swayAmp: Range + /** Peak tilt into the sway (deg), uniform. */ + bank: Range + /** Sway period (ms), uniform — independent of the rise so paths never repeat. */ + swayDuration: Range + /** Cap on simultaneously-alive particles. */ + maxAlive: number +} + +export const DEFAULT_PARTICLE_CONFIG: ParticleFieldConfig = { + count: 12, + spawnWindowMs: 550, + size: [6, 13], + rise: [6.75, 15.75], + duration: [320, 700], + swayAmp: [9, 24], + bank: [7, 16], + swayDuration: [1300, 2800], + maxAlive: 200 +} + +export interface ParticleEmitter { + /** Fire a burst (defaults to the field's configured `count`). */ + burst: (count?: number) => void + /** Internal: field subscription. */ + subscribe: (fn: (count?: number) => void) => () => void +} + +/** Create an emitter handle. `burst()` is safe to call from anywhere. */ +export function createParticleEmitter(): ParticleEmitter { + const listeners = new Set<(count?: number) => void>() + + return { + burst: count => listeners.forEach(fn => fn(count)), + subscribe: fn => { + listeners.add(fn) + + return () => void listeners.delete(fn) + } + } +} + +interface Particle { + id: number + leftPct: number + size: number + color: string + delayMs: number + durationMs: number + rise: number + swayAmp: number + bank: number + swayDurationMs: number + swayDelayMs: number +} + +let nextId = 1 + +function spawn(cfg: ParticleFieldConfig, colors: readonly string[]): Particle { + // Short-lived particles fade out lower; a few live longer and rise higher. + const life = Math.random() ** 1.7 + const swayDurationMs = Math.round(rand(cfg.swayDuration)) + + return { + id: nextId++, + // Spread edge to edge across the lane, not clustered near center. + leftPct: 4 + Math.random() * 92, + size: rand(cfg.size), + color: colors[Math.floor(Math.random() * colors.length)]!, + delayMs: Math.round(Math.random() * 120), + durationMs: Math.round(lerp(cfg.duration, life)), + rise: lerp(cfg.rise, life), + swayAmp: rand(cfg.swayAmp), + bank: rand(cfg.bank), + swayDurationMs, + // Negative delay drops each particle in mid-swing (desynced phases). + swayDelayMs: -Math.round(Math.random() * swayDurationMs) + } +} + +const prefersReducedMotion = () => + typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) + +export interface ParticleFieldProps { + emitter: ParticleEmitter + /** Any element that paints with `currentColor` (SVG, glyph, …). */ + glyph: ReactNode + colors: readonly string[] + config?: Partial<ParticleFieldConfig> + className?: string + style?: CSSProperties +} + +export function ParticleField({ emitter, glyph, colors, config, className, style }: ParticleFieldProps) { + const cfg = useMemo(() => ({ ...DEFAULT_PARTICLE_CONFIG, ...config }), [config]) + const [particles, setParticles] = useState<Particle[]>([]) + const timers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set()) + + useEffect(() => { + const pool = timers.current + const add = () => setParticles(prev => [...prev, spawn(cfg, colors)].slice(-cfg.maxAlive)) + + // Release a burst across a tight window so it reads as one poof, each node + // with its own random birth time; reduced motion gets a single flash. + const onBurst = (count?: number) => { + const n = Math.max(1, Math.min(cfg.maxAlive, Math.round(count ?? cfg.count))) + + if (prefersReducedMotion()) { + add() + + return + } + + for (let i = 0; i < n; i++) { + const timer = setTimeout(() => { + pool.delete(timer) + add() + }, Math.random() * cfg.spawnWindowMs) + + pool.add(timer) + } + } + + const unsubscribe = emitter.subscribe(onBurst) + + return () => { + unsubscribe() + pool.forEach(clearTimeout) + pool.clear() + } + }, [cfg, colors, emitter]) + + const remove = (id: number) => setParticles(prev => prev.filter(p => p.id !== id)) + + if (particles.length === 0) { + return null + } + + return ( + <div aria-hidden className={cn('particle-field', className)} style={style}> + {particles.map(p => ( + <span + className="particle" + key={p.id} + // Retire on the RISE track only (sway is infinite, pop is shorter). + onAnimationEnd={e => { + if (e.animationName === 'particle-rise' || e.animationName === 'particle-flash') { + remove(p.id) + } + }} + style={ + { + '--particle-left': `${p.leftPct}%`, + '--particle-size': `${p.size}px`, + '--particle-color': p.color, + '--particle-delay': `${p.delayMs}ms`, + '--particle-duration': `${p.durationMs}ms`, + '--particle-rise': p.rise, + '--particle-sway': `${p.swayAmp}px`, + '--particle-bank': `${p.bank}deg`, + '--particle-sway-duration': `${p.swayDurationMs}ms`, + '--particle-sway-delay': `${p.swayDelayMs}ms` + } as CSSProperties + } + > + <span className="particle__sway"> + <span className="particle__glyph">{glyph}</span> + </span> + </span> + ))} + </div> + ) +} diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 5ead9838dc7..399ed1bf485 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { useOnProfileSwitch } from '@/app/hooks/use-on-profile-switch' import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active' +import { PetHeartField } from '@/components/chat/vibe-hearts' import { persistString, storedString } from '@/lib/storage' import { $petAtRest, @@ -220,7 +221,7 @@ export function FloatingPet() { }) // Wire the overlay control channel once, only in the primary window — the - // pop-out overlay belongs to it (main.cjs positions it against the main + // pop-out overlay belongs to it (main.ts positions it against the main // window and routes control messages back to it). useEffect(() => { if (isSecondaryWindow()) { @@ -447,6 +448,9 @@ export function FloatingPet() { > <PetSprite info={info} rowOverride={walk.row} /> </div> + {/* Hearts puff off the pet; its celebrate ("yay"/jump) pose is driven by + burstVibeHearts's router. */} + <PetHeartField petH={petH} petW={petW} /> </div> ) } diff --git a/apps/desktop/src/components/prompt-overlays.test.tsx b/apps/desktop/src/components/prompt-overlays.test.tsx new file mode 100644 index 00000000000..7b8cb2148f5 --- /dev/null +++ b/apps/desktop/src/components/prompt-overlays.test.tsx @@ -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() + }) +}) diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx index a43303e1ced..cf56d62e85a 100644 --- a/apps/desktop/src/components/prompt-overlays.tsx +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -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) } diff --git a/apps/desktop/src/components/ui/button.tsx b/apps/desktop/src/components/ui/button.tsx index 06abd4b7945..10a107727a5 100644 --- a/apps/desktop/src/components/ui/button.tsx +++ b/apps/desktop/src/components/ui/button.tsx @@ -53,6 +53,14 @@ const buttonVariants = cva( 'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]' } }, + compoundVariants: [ + // textStrong is a boxless link — size variants still inject px-*; strip + // inline padding so the underline sits flush with the label. + { + variant: 'textStrong', + class: 'px-0 has-[>svg]:px-0' + } + ], defaultVariants: { variant: 'default', size: 'default' diff --git a/apps/desktop/src/components/ui/copy-button.tsx b/apps/desktop/src/components/ui/copy-button.tsx index ff7663ff94a..cee6edcde88 100644 --- a/apps/desktop/src/components/ui/copy-button.tsx +++ b/apps/desktop/src/components/ui/copy-button.tsx @@ -233,5 +233,11 @@ export function CopyButton({ ) // Only icon-only buttons need a tooltip; the text variant already shows its label. - return appearance === 'icon' ? <Tip label={feedbackLabel} side={side ?? 'bottom'}>{button}</Tip> : button + return appearance === 'icon' ? ( + <Tip label={feedbackLabel} side={side ?? 'bottom'}> + {button} + </Tip> + ) : ( + button + ) } diff --git a/apps/desktop/src/components/ui/tooltip.test.tsx b/apps/desktop/src/components/ui/tooltip.test.tsx new file mode 100644 index 00000000000..ead0cda08fb --- /dev/null +++ b/apps/desktop/src/components/ui/tooltip.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { Tip, TipHintLabel } from './tooltip' + +describe('Tip', () => { + afterEach(() => { + cleanup() + }) + + it('shows on pointer enter and dismisses on pointer leave', async () => { + render( + <Tip label="Layout editor — ⌘-click resets the layout"> + <button type="button">layout</button> + </Tip> + ) + + const trigger = screen.getByRole('button', { name: 'layout' }) + + fireEvent.pointerMove(trigger, { pointerType: 'mouse' }) + expect((await screen.findByRole('tooltip')).textContent).toContain('Layout editor — ⌘-click resets the layout') + + fireEvent.pointerLeave(trigger) + await waitFor(() => { + expect(screen.queryByRole('tooltip')).toBeNull() + }) + }) + + it('renders the child alone when label is empty', () => { + render( + <Tip label=""> + <button type="button">bare</button> + </Tip> + ) + + expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy() + expect(screen.queryByRole('tooltip')).toBeNull() + }) + + it('guards a block-level label child via the decoration wrapper class', async () => { + render( + <Tip label={<span className="flex items-center gap-2">broken label</span>}> + <button type="button">trigger</button> + </Tip> + ) + + fireEvent.pointerMove(screen.getByRole('button', { name: 'trigger' }), { pointerType: 'mouse' }) + await screen.findByRole('tooltip') + + // jsdom applies no real Tailwind, so assert the guarding class is present on + // the decoration wrapper — that's what forces any direct child inline-flex + // in a browser (#62022). + const decoration = document.querySelector<HTMLElement>('[data-slot="tooltip-content"]')?.firstElementChild + + expect(decoration?.className).toMatch(/\[&>\*\]:!inline-flex/) + }) +}) + +describe('TipHintLabel', () => { + afterEach(() => { + cleanup() + }) + + it('renders inline-flex with a hint and plain text without one', () => { + const { rerender } = render(<TipHintLabel hint="Ctrl+`" text="PowerShell" />) + const withHint = screen.getByText('PowerShell').parentElement + + expect(withHint?.classList.contains('inline-flex')).toBe(true) + expect(withHint?.classList.contains('flex')).toBe(false) + + rerender(<TipHintLabel text="PowerShell" />) + expect(screen.getByText('PowerShell').tagName).not.toBe('SPAN') + }) +}) diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index b3e012d976f..669a0eada7e 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -3,8 +3,23 @@ import * as React from 'react' import { cn } from '@/lib/utils' -function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) { - return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} /> +function TooltipProvider({ + delayDuration = 0, + // Tips are labels, not interactive surfaces. Hoverable content + Radix's + // pointer-grace bridge is what leaves tips stuck open — especially over + // Electron `-webkit-app-region: drag` chrome where pointermove never fires + // to clear the grace area. Default off so open state tracks the trigger only. + disableHoverableContent = true, + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Provider>) { + return ( + <TooltipPrimitive.Provider + data-slot="tooltip-provider" + delayDuration={delayDuration} + disableHoverableContent={disableHoverableContent} + {...props} + /> + ) } function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) { @@ -24,18 +39,27 @@ function TooltipContent({ return ( <TooltipPrimitive.Portal> <TooltipPrimitive.Content - // Instant, no transition (the Provider's delayDuration=0 + no animate-* - // classes). bg-foreground/text-background auto-inverts per theme: white - // on near-black in light mode, black on white in dark. - className={cn( - 'z-[200] w-fit bg-foreground px-1.5 py-1 text-[11px] font-bold leading-none text-background select-none [font-family:Arial,sans-serif]', - className - )} + // Transparent, width-capped wrapper. The visible chip is the inner inline + // span so `box-decoration-break: clone` gives a marker-style background + // that hugs EACH wrapped line (bg only on the text, ragged right — no + // rectangular dead space). Instant, no transition (delayDuration=0). + // pointer-events-none: the tip must never steal hover/clicks from the + // chrome underneath (titlebar tools, adjacent tabs, etc.). + className={cn('pointer-events-none z-[200] w-fit max-w-64 select-none', className)} data-slot="tooltip-content" sideOffset={sideOffset} {...props} > - {children} + {/* bg-foreground/text-background auto-inverts per theme. leading-normal + keeps lines readable; py-1 makes the cloned line-boxes overlap just + enough to read as one continuous fill (no gaps between lines). */} + {/* [&>*]:!inline-flex: a block-level label child (e.g. `flex`) collapses + this inline decoration's geometry, so Radix measures a zero-size chip + and parks an empty rectangle in the corner (#62022). Force any direct + child inline-flex so every call site stays safe. */} + <span className="box-decoration-clone inline bg-foreground px-1.5 py-1 text-[11px] font-bold leading-normal text-background [font-family:Arial,sans-serif] [&>*]:!inline-flex"> + {children} + </span> </TooltipPrimitive.Content> </TooltipPrimitive.Portal> ) @@ -50,15 +74,15 @@ interface TipProps extends Omit<React.ComponentProps<typeof TooltipPrimitive.Con // Drop-in replacement for native `title=`: wrap any single element. Instant, // position-aware, themed. Self-contained (carries its own Provider) so it works // anywhere without a provider ancestor. Renders the child untouched when label -// is falsy. +// is falsy. Open state is trigger-hover only — never sticky, never click-blocking. function Tip({ label, children, delayDuration = 0, ...props }: TipProps) { if (!label) { return <>{children}</> } return ( - <TooltipProvider delayDuration={delayDuration}> - <Tooltip> + <TooltipProvider delayDuration={delayDuration} disableHoverableContent> + <Tooltip disableHoverableContent> <TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipContent {...props}>{label}</TooltipContent> </Tooltip> @@ -66,4 +90,25 @@ function Tip({ label, children, delayDuration = 0, ...props }: TipProps) { ) } -export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } +interface TipHintLabelProps { + text: string + hint?: string +} + +/** Tooltip label with an optional trailing hotkey hint. Uses `inline-flex` so it + * stays safe inside Tip's decoration wrapper — prefer this over a bespoke + * flex/gap span at the call site (see #62022). */ +function TipHintLabel({ text, hint }: TipHintLabelProps) { + if (!hint) { + return <>{text}</> + } + + return ( + <span className="inline-flex items-center gap-2"> + <span>{text}</span> + <span className="opacity-55">{hint}</span> + </span> + ) +} + +export { Tip, TipHintLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index f9c5e34f541..0ac5ace5213 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -55,6 +55,15 @@ declare global { probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult> oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult> oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult> + // Hermes Cloud: one portal login powers discovery + silent per-agent + // sign-in (cloud-auto-discovery Phase 3). + cloud: { + status: () => Promise<DesktopCloudStatus> + login: () => Promise<DesktopCloudStatus & { ok: boolean }> + logout: () => Promise<DesktopCloudStatus & { ok: boolean }> + discover: (org?: string) => Promise<DesktopCloudDiscoverResult> + agentSignIn: (dashboardUrl: string) => Promise<DesktopCloudAgentSignInResult> + } profile: { get: () => Promise<DesktopActiveProfile> // Persists the desktop's profile choice and relaunches the local @@ -121,6 +130,10 @@ declare global { branchSwitch: (repoPath: string, branch: string) => Promise<{ branch: string }> // Local branches for the "convert a branch into a worktree" picker. branchList: (repoPath: string) => Promise<HermesGitBranch[]> + // Local + remote-tracking branches for the "base branch" picker in the + // new-worktree dialog. The remote default (origin/HEAD) is flagged so + // the UI can preselect it. + baseBranchList: (repoPath: string) => Promise<HermesGitBaseBranch[]> // Compact working-tree status for the composer coding rail. Null on a // non-repo / remote backend (where the Electron probe can't run). repoStatus: (repoPath: string) => Promise<HermesRepoStatus | null> @@ -154,6 +167,10 @@ declare global { scanRepos: (roots: string[], options?: { maxDepth?: number }) => Promise<{ root: string; label: string }[]> } terminal: { + /** Best-effort current working directory of the live PTY child (POSIX + * only; null on Windows or when unavailable). Used to reopen a tab + * where the user last `cd`'d. */ + cwd: (id: string) => Promise<string | null> dispose: (id: string) => Promise<boolean> onData: (id: string, callback: (payload: string) => void) => () => void onExit: (id: string, callback: (payload: HermesTerminalExit) => void) => () => void @@ -172,6 +189,9 @@ declare global { onNotificationAction?: (callback: (payload: { actionId: string; sessionId?: string }) => void) => () => void onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void onBackendExit: (callback: (payload: BackendExit) => void) => () => void + // Soft gateway-mode apply: primary backend was torn down without a window + // reload. Wipe session lists (skeletons) and re-dial. + onConnectionApplied?: (callback: () => void) => () => void onPowerResume?: (callback: () => void) => () => void onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void getBootstrapState: () => Promise<DesktopBootstrapState> @@ -359,6 +379,9 @@ export interface DesktopUpdateProgress { export interface HermesConnection { baseUrl: string isFullscreen: boolean + // The live, RESOLVED connection mode. Only ever 'local' or 'remote' — a + // 'cloud' saved-config entry resolves to a 'remote' connection under the hood + // (cloud-auto-discovery Q3/Q6), so this never carries 'cloud'. mode?: 'local' | 'remote' authMode?: 'oauth' | 'token' nativeOverlayWidth: number @@ -391,7 +414,12 @@ export interface DesktopActiveProfile { export interface DesktopConnectionConfig { envOverride: boolean - mode: 'local' | 'remote' + // The saved connection mode. 'cloud' is a Hermes Cloud connection: it carries + // a remote-shaped block (remoteUrl = the selected agent's dashboardUrl, + // remoteAuthMode 'oauth') but is remembered as cloud so settings reopens into + // the cloud picker. Resolution treats cloud exactly as remote + // (cloud-auto-discovery Q3/Q6). + mode: 'local' | 'remote' | 'cloud' // The profile this config describes, or null for the global/default // connection. Per-profile entries let a profile point at its own backend. profile: null | string @@ -400,16 +428,23 @@ export interface DesktopConnectionConfig { remoteTokenPreview: string | null remoteTokenSet: boolean remoteUrl: string + // For a 'cloud' connection: the persisted Hermes Cloud org (slug or id) the + // connected instance was discovered under, so Settings → Gateway can reopen + // into that org. Empty string for remote/local. + cloudOrg: string } export interface DesktopConnectionConfigInput { - mode: 'local' | 'remote' + mode: 'local' | 'remote' | 'cloud' // When set, the save/apply/test targets this profile's per-profile remote // override instead of the global connection. profile?: null | string remoteAuthMode?: 'oauth' | 'token' remoteToken?: string remoteUrl?: string + // For a 'cloud' connection: the selected Hermes Cloud org (slug or id) to + // persist so Settings can reopen into it. Ignored for remote/local modes. + cloudOrg?: string } export interface DesktopConnectionTestResult { @@ -448,6 +483,55 @@ export interface DesktopOauthLogoutResult { connected: boolean } +// --- Hermes Cloud (cloud-auto-discovery Phase 3) --- + +export interface DesktopCloudStatus { + // The portal base URL the desktop talks to (default or env-overridden). + portalBaseUrl: string + // Whether the OAuth partition holds a live Nous portal (Privy) session — the + // portal authenticates via Privy, so this reflects the privy-token cookie, NOT + // the hermes gateway session cookies. See cookiesHavePrivySession. + signedIn: boolean +} + +// A discovered Hermes Cloud agent — the trimmed DTO from NAS GET /api/agents. +export interface DesktopCloudAgent { + id: string + name: string + status: string + // null until the agent has a provisioned dashboard (show "provisioning…"). + dashboardUrl: string | null + // "active" | "degraded" | "down" | "unknown". + dashboardGatewayState: string +} + +// An org the signed-in user belongs to — for the org picker shown when a +// multi-org user's discovery call needs disambiguation (NAS 409). +export interface DesktopCloudOrg { + id: string + slug: string | null + name: string + isPersonal: boolean + // "OWNER" | "MEMBER". + role: string +} + +// Discovery result: either the agent list, OR a request to pick an org first +// (multi-org user, no org chosen yet). The renderer shows a picker on the +// latter and re-calls discover(org). On the agents branch, `org` echoes the +// authoritatively-resolved org the list was scoped to (from NAS), so the +// desktop persists it without relying on transient picker state. +export type DesktopCloudDiscoverResult = + | { agents: DesktopCloudAgent[]; org?: DesktopCloudOrg | null; needsOrgSelection?: false } + | { needsOrgSelection: true; orgs: DesktopCloudOrg[] } + +export interface DesktopCloudAgentSignInResult { + // The agent gateway base URL the silent sign-in targeted. + baseUrl: string + // Whether the agent's gateway session cookie landed (silent cascade done). + connected: boolean +} + export interface DesktopBootProgress { error: string | null fakeMode: boolean @@ -459,7 +543,7 @@ export interface DesktopBootProgress { } // First-launch install ("bootstrap") event types -- emitted by -// electron/bootstrap-runner.cjs and observed by the renderer install overlay. +// electron/bootstrap-runner.ts and observed by the renderer install overlay. // Mirrors the event shapes emitted by runBootstrap()'s onEvent callback. export interface DesktopBootstrapStageDescriptor { @@ -588,6 +672,16 @@ export interface HermesGitBranch { worktreePath: null | string } +// A branch the new worktree can be based on: local heads + remote-tracking +// refs. `isRemote` distinguishes `origin/main` from a local `main` (the UI +// may show a remote glyph); `isDefault` flags origin/HEAD so the dialog can +// preselect it. +export interface HermesGitBaseBranch { + name: string + isRemote: boolean + isDefault: boolean +} + // A single changed path from `git status --porcelain=v2`, classified by state // so the coding rail / switcher can group + open the right diff. export interface HermesRepoStatusFile { diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index b97ba86c8cd..7481badf562 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -64,7 +64,7 @@ import type { // model info/options, cron) the moment the backend passes readiness. On a // profile-heavy or remote install these can each take tens of seconds — e.g. // /api/profiles runs list_profiles(), which does a recursive skill-tree walk -// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.cjs) +// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts) // times out a backend that is alive-but-busy, surfacing as a spurious // "Timed out connecting to Hermes backend" that hangs the UI (#48504). // diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6b1bf4eadcf..6b661c4acbf 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -90,9 +90,14 @@ export const en: Translations = { retry: 'Retry', repairInstall: 'Repair install', useLocalGateway: 'Use local gateway', + gatewaySettings: 'Gateway settings', + back: 'Back', openLogs: 'Open logs', repairHint: 'Repair re-runs the installer and can take a few minutes on a fresh machine.', - remoteSignInHint: 'Opens the gateway login window. Use local gateway to switch to the bundled backend instead.', + remoteSignInHint: signInLabel => + `Signs out of the saved remote browser session, then opens ${signInLabel}. Use local gateway to switch to the bundled backend instead.`, + signOutAndSignIn: 'Sign out & sign in', + remoteFailureHint: 'Check the gateway URL and sign-in under Gateway settings, or switch to the local gateway.', hideRecentLogs: 'Hide recent logs', showRecentLogs: 'Show recent logs', signedInTitle: 'Signed in', @@ -530,11 +535,44 @@ export const en: Translations = { envOverrideTitle: 'Environment variables are controlling this desktop session.', envOverrideDesc: 'Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved setting below.', + modeTitle: 'Connection mode', localTitle: 'Local gateway', localDesc: 'Start a private Hermes backend on localhost. This is the default and works offline.', remoteTitle: 'Remote gateway', - remoteDesc: - 'Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.', + remoteDesc: 'Connect this desktop shell to a remote Hermes backend.', + remoteAuthHint: + 'Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.', + cloudTitle: 'Hermes Cloud', + cloudDesc: 'Sign in once to Hermes Cloud and pick from the agents on your account — no URL to paste.', + cloudSignInTitle: 'Hermes Cloud', + cloudSignIn: 'Sign in to Hermes Cloud', + cloudSignedIn: 'Signed in to Hermes Cloud', + cloudNeedsSignIn: 'Sign in to Hermes Cloud to discover the agents on your account.', + cloudSignedInDesc: 'You are signed in. Pick an agent below; the session refreshes automatically.', + cloudAgentsTitle: 'Your agents', + cloudOrgPickerTitle: 'Choose an organization', + cloudOrgSelect: 'Select', + cloudOrgChange: 'Change org', + cloudOrgRole: role => `Role: ${role}`, + cloudLoadingAgents: 'Loading your agents…', + cloudNoAgents: { + before: 'No agents found on this account. Create one in the ', + linkText: 'Nous portal', + after: ', then refresh.' + }, + cloudRefresh: 'Refresh', + cloudConnect: 'Connect', + cloudConnecting: 'Connecting…', + cloudDiscoverFailed: 'Could not load your Hermes Cloud agents', + cloudConnectFailed: 'Could not connect to that agent', + cloudSignInFailed: 'Hermes Cloud sign-in failed', + cloudSignedOutTitle: 'Signed out of Hermes Cloud', + cloudSignedOutMessage: 'Cleared the Hermes Cloud session.', + cloudConnectedTitle: 'Connected', + cloudConnectedPill: 'Connected', + cloudConnectedTo: name => `Connected to ${name}.`, + cloudAgentProvisioning: 'Provisioning…', + cloudStatusLabel: status => `Status: ${status}`, remoteUrlTitle: 'Remote URL', remoteUrlDesc: 'Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes.', probing: 'Checking how this gateway authenticates…', @@ -568,7 +606,7 @@ export const en: Translations = { enterUrlFirst: 'Enter a remote URL first.', restartingTitle: 'Gateway connection restarting', savedTitle: 'Gateway settings saved', - restartingMessage: 'Hermes Desktop will reconnect using the saved settings.', + restartingMessage: 'Hermes Desktop will reconnect using the saved settings — the shell stays open.', savedMessage: 'Saved for the next restart.', connectedTo: (baseUrl, version) => `Connected to ${baseUrl}${version ? ` · Hermes ${version}` : ''}`, reachableTitle: 'Remote gateway reachable', @@ -669,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' }, @@ -1455,7 +1496,10 @@ export const en: Translations = { customPlaceholder: '0 9 * * * or weekdays at 9am', customHint: 'Cron expression, or phrases like "every hour" or "weekdays at 9am".', optional: 'Optional', + promptRequired: 'Prompt is required.', promptScheduleRequired: 'Prompt and schedule are required.', + scheduleRequired: 'Schedule is required.', + scriptOnlyEditHint: 'Script-only job (no AI prompt). Job id:', saveChanges: 'Save changes', createAction: 'Create cron' }, @@ -1559,6 +1603,9 @@ export const en: Translations = { newWorktreeTitle: 'New worktree', newWorktreeDesc: 'Name the branch for this worktree.', branchPlaceholder: 'e.g. my-feature', + branchOff: () => ({ after: '', before: 'branch off ' }), + baseBranchPlaceholder: 'Search branches…', + baseBranchNone: 'No branches found', startWorkFailed: 'Could not create worktree', convertBranch: 'Convert a branch…', convertBranchTitle: 'Convert a branch', @@ -1922,6 +1969,10 @@ export const en: Translations = { connectFailed: 'Could not connect to the local server.' }, apiKeyOptions: { + fireworks: { + short: 'direct model API', + description: 'Direct access to models hosted by Fireworks AI.' + }, openrouter: { short: 'one key, many models', description: 'Hosts hundreds of models behind a single key. Good default for new installs.' @@ -2026,7 +2077,9 @@ export const en: Translations = { low: 'Low', medium: 'Medium', high: 'High', + xhigh: 'Extra High', max: 'Max', + ultra: 'Ultra', updateFailed: 'Model option update failed', fastFailed: 'Fast mode update failed' }, @@ -2045,6 +2098,16 @@ export const en: Translations = { viewAllLogs: 'View all logs →', messagingPlatforms: 'Messaging platforms' }, + approvalMode: { + title: 'Approval mode', + ariaLabel: mode => `Approval mode: ${mode}`, + manual: 'Manual', + manualDescription: 'Ask before actions that require approval', + smart: 'Smart', + smartDescription: 'Automatically assess actions and ask when needed', + off: 'Off', + offDescription: 'Run without approval prompts' + }, statusbar: { unknown: 'unknown', restart: 'restart', @@ -2304,6 +2367,7 @@ export const en: Translations = { other: 'Other (type your answer)', placeholder: 'Type your answer…', skip: 'Skip', + skipped: 'Skipped', continueLabel: 'Continue' }, tool: { diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 18409777302..eb6acaf1b85 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -90,10 +90,14 @@ export const ja = defineLocale({ retry: '再試行', repairInstall: 'インストールを修復', useLocalGateway: 'ローカルゲートウェイを使用', + gatewaySettings: 'ゲートウェイ設定', + back: '戻る', openLogs: 'ログを開く', repairHint: '修復はインストーラーを再実行します。新しいマシンでは数分かかる場合があります。', - remoteSignInHint: - 'ゲートウェイのログインウィンドウを開きます。代わりにバンドルされたバックエンドに切り替えるには「ローカルゲートウェイを使用」を選択してください。', + remoteSignInHint: signInLabel => + `保存済みのリモートブラウザセッションからサインアウトし、${signInLabel}を開きます。代わりにバンドルされたバックエンドに切り替えるには「ローカルゲートウェイを使用」を選択してください。`, + signOutAndSignIn: 'サインアウトして再サインイン', + remoteFailureHint: '「ゲートウェイ設定」でゲートウェイの URL とサインインを確認するか、ローカルゲートウェイに切り替えてください。', hideRecentLogs: '最近のログを非表示', showRecentLogs: '最近のログを表示', signedInTitle: 'サインインしました', @@ -1415,7 +1419,10 @@ export const ja = defineLocale({ customPlaceholder: '0 9 * * * または weekdays at 9am', customHint: 'Cron 式、または「every hour」「weekdays at 9am」のようなフレーズ。', optional: '省略可能', + promptRequired: 'プロンプトは必須です。', promptScheduleRequired: 'プロンプトとスケジュールは必須です。', + scheduleRequired: 'スケジュールは必須です。', + scriptOnlyEditHint: 'スクリプトのみのジョブ(AI プロンプトなし)。ジョブ ID:', saveChanges: '変更を保存', createAction: 'Cron を作成' }, @@ -1520,6 +1527,9 @@ export const ja = defineLocale({ newWorktreeTitle: '新しいワークツリー', newWorktreeDesc: 'このワークツリーのブランチ名を入力してください。', branchPlaceholder: '例: my-feature', + branchOff: () => ({ after: ' から分岐', before: '' }), + baseBranchPlaceholder: 'ブランチを検索…', + baseBranchNone: 'ブランチが見つかりません', startWorkFailed: 'ワークツリーを作成できませんでした', convertBranch: 'ブランチを変換…', convertBranchTitle: 'ブランチを変換', @@ -1881,6 +1891,10 @@ export const ja = defineLocale({ connectFailed: 'ローカルサーバーに接続できませんでした。' }, apiKeyOptions: { + fireworks: { + short: 'モデル API に直接接続', + description: 'Fireworks AI がホストするモデルに直接アクセスします。' + }, openrouter: { short: '1 つのキーで多くのモデル', description: '1 つのキーで数百のモデルをホスト。新規インストールのデフォルトとして最適。' @@ -1985,7 +1999,9 @@ export const ja = defineLocale({ low: '低', medium: '中', high: '高', + xhigh: '特高', max: '最大', + ultra: 'ウルトラ', updateFailed: 'モデルオプションの更新に失敗しました', fastFailed: '高速モードの更新に失敗しました' }, @@ -2004,6 +2020,16 @@ export const ja = defineLocale({ viewAllLogs: 'すべてのログを見る →', messagingPlatforms: 'メッセージングプラットフォーム' }, + approvalMode: { + title: '承認モード', + ariaLabel: mode => `承認モード: ${mode}`, + manual: '手動', + manualDescription: '承認が必要な操作の前に確認します', + smart: 'スマート', + smartDescription: '必要な場合にのみ確認します', + off: 'オフ', + offDescription: '承認プロンプトなしで実行します' + }, statusbar: { unknown: '不明', restart: '再起動', @@ -2261,6 +2287,7 @@ export const ja = defineLocale({ other: 'その他(回答を入力)', placeholder: '回答を入力…', skip: 'スキップ', + skipped: 'スキップ済み', continueLabel: '続行' }, tool: { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index bfc9dd2d109..78b599e6314 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -133,9 +133,13 @@ export interface Translations { retry: string repairInstall: string useLocalGateway: string + gatewaySettings: string + back: string openLogs: string repairHint: string - remoteSignInHint: string + remoteSignInHint: (signInLabel: string) => string + signOutAndSignIn: string + remoteFailureHint: string hideRecentLogs: string showRecentLogs: string signedInTitle: string @@ -442,10 +446,39 @@ export interface Translations { profileConnection: (profile: string) => string envOverrideTitle: string envOverrideDesc: string + modeTitle: string localTitle: string localDesc: string remoteTitle: string remoteDesc: string + remoteAuthHint: string + cloudTitle: string + cloudDesc: string + cloudSignInTitle: string + cloudSignIn: string + cloudSignedIn: string + cloudNeedsSignIn: string + cloudSignedInDesc: string + cloudAgentsTitle: string + cloudOrgPickerTitle: string + cloudOrgSelect: string + cloudOrgChange: string + cloudOrgRole: (role: string) => string + cloudLoadingAgents: string + cloudNoAgents: { before: string; linkText: string; after: string } + cloudRefresh: string + cloudConnect: string + cloudConnecting: string + cloudDiscoverFailed: string + cloudConnectFailed: string + cloudSignInFailed: string + cloudSignedOutTitle: string + cloudSignedOutMessage: string + cloudConnectedTitle: string + cloudConnectedPill: string + cloudConnectedTo: (name: string) => string + cloudAgentProvisioning: string + cloudStatusLabel: (status: string) => string remoteUrlTitle: string remoteUrlDesc: string probing: string @@ -578,6 +611,9 @@ export interface Translations { change: string autoUseMain: string providerDefault: string + fallbackAdd: string + fallbackEmpty: string + notInCatalog: string tasks: Record<string, AuxTaskCopy> } ollama: { @@ -1192,7 +1228,10 @@ export interface Translations { customPlaceholder: string customHint: string optional: string + promptRequired: string promptScheduleRequired: string + scheduleRequired: string + scriptOnlyEditHint: string saveChanges: string createAction: string } @@ -1290,6 +1329,9 @@ export interface Translations { newWorktreeTitle: string newWorktreeDesc: string branchPlaceholder: string + branchOff: () => { after: string; before: string } + baseBranchPlaceholder: string + baseBranchNone: string startWorkFailed: string convertBranch: string convertBranchTitle: string @@ -1665,7 +1707,9 @@ export interface Translations { low: string medium: string high: string + xhigh: string max: string + ultra: string updateFailed: string fastFailed: string } @@ -1684,6 +1728,16 @@ export interface Translations { viewAllLogs: string messagingPlatforms: string } + approvalMode: { + title: string + ariaLabel: (mode: string) => string + manual: string + manualDescription: string + smart: string + smartDescription: string + off: string + offDescription: string + } statusbar: { unknown: string restart: string @@ -1935,6 +1989,7 @@ export interface Translations { other: string placeholder: string skip: string + skipped: string continueLabel: string } tool: { diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 19bbcf71ac8..4142278748b 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -88,9 +88,14 @@ export const zhHant = defineLocale({ retry: '重試', repairInstall: '修復安裝', useLocalGateway: '使用本機閘道', + gatewaySettings: '閘道設定', + back: '返回', openLogs: '開啟記錄', repairHint: '修復會重新執行安裝程式,在新機器上可能需要幾分鐘。', - remoteSignInHint: '開啟閘道登入視窗。使用本機閘道可切換至內建後端。', + remoteSignInHint: signInLabel => + `先登出已儲存的遠端瀏覽器工作階段,然後開啟${signInLabel}。使用本機閘道可切換至內建後端。`, + signOutAndSignIn: '登出並重新登入', + remoteFailureHint: '在「閘道設定」中檢查閘道 URL 與登入,或切換至本機閘道。', hideRecentLogs: '隱藏最近記錄', showRecentLogs: '顯示最近記錄', signedInTitle: '已登入', @@ -281,7 +286,8 @@ export const zhHant = defineLocale({ toolViewTitle: '工具呼叫顯示', toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。', uiScaleTitle: '介面縮放', - uiScaleDesc: (percent: number) => `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, + uiScaleDesc: (percent: number) => + `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, translucencyTitle: '視窗透明', translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', embedsTitle: '內嵌預覽', @@ -1367,7 +1373,10 @@ export const zhHant = defineLocale({ customPlaceholder: '0 9 * * * 或 weekdays at 9am', customHint: 'Cron 表達式,或類似「每小時」「工作日上午 9 點」的短語。', optional: '選填', + promptRequired: '提示詞為必填項目。', promptScheduleRequired: '提示詞和排程為必填項目。', + scheduleRequired: '排程為必填項目。', + scriptOnlyEditHint: '僅腳本任務(無 AI 提示詞)。任務 ID:', saveChanges: '儲存變更', createAction: '建立排程工作' }, @@ -1470,6 +1479,9 @@ export const zhHant = defineLocale({ newWorktreeTitle: '新增工作樹', newWorktreeDesc: '為這個工作樹命名分支。', branchPlaceholder: '例如 my-feature', + branchOff: () => ({ after: ' 分支', before: '從 ' }), + baseBranchPlaceholder: '搜尋分支…', + baseBranchNone: '未找到分支', startWorkFailed: '無法建立工作樹', convertBranch: '轉換分支…', convertBranchTitle: '轉換分支', @@ -1824,6 +1836,7 @@ export const zhHant = defineLocale({ connectFailed: '無法連線到本機伺服器。' }, apiKeyOptions: { + fireworks: { short: '直接模型 API', description: '直接存取 Fireworks AI 託管的模型。' }, openrouter: { short: '一個金鑰,多個模型', description: '用一個金鑰存取數百個模型。適合新安裝的預設選擇。' }, openai: { short: 'GPT 等級模型', description: '直接存取 OpenAI 模型。' }, gemini: { short: 'Gemini 模型', description: '直接存取 Google Gemini 模型。' }, @@ -1922,7 +1935,9 @@ export const zhHant = defineLocale({ low: '低', medium: '中', high: '高', + xhigh: '極高', max: '最高', + ultra: '超高', updateFailed: '模型選項更新失敗', fastFailed: '快速模式更新失敗' }, @@ -1941,6 +1956,16 @@ export const zhHant = defineLocale({ viewAllLogs: '查看全部記錄 →', messagingPlatforms: '訊息平台' }, + approvalMode: { + title: '核准模式', + ariaLabel: mode => `核准模式:${mode}`, + manual: '手動', + manualDescription: '執行需要核准的操作前詢問', + smart: '智慧', + smartDescription: '自動評估操作,並在需要時詢問', + off: '關閉', + offDescription: '不顯示核准提示,直接執行' + }, statusbar: { unknown: '未知', restart: '重新啟動', @@ -2193,6 +2218,7 @@ export const zhHant = defineLocale({ other: '其他(輸入您的答案)', placeholder: '輸入您的答案…', skip: '略過', + skipped: '已略過', continueLabel: '繼續' }, tool: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 921a8dc135e..45371c3d960 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -88,9 +88,14 @@ export const zh: Translations = { retry: '重试', repairInstall: '修复安装', useLocalGateway: '使用本地网关', + gatewaySettings: '网关设置', + back: '返回', openLogs: '打开日志', repairHint: '修复会重新运行安装器,在新机器上可能需要几分钟。', - remoteSignInHint: '打开网关登录窗口。也可以使用本地网关切换到随应用提供的后端。', + remoteSignInHint: signInLabel => + `先退出已保存的远程浏览器会话,然后打开${signInLabel}。也可以使用本地网关切换到随应用提供的后端。`, + signOutAndSignIn: '退出并重新登录', + remoteFailureHint: '在“网关设置”中检查网关 URL 和登录,或切换到本地网关。', hideRecentLogs: '隐藏最近日志', showRecentLogs: '显示最近日志', signedInTitle: '已登录', @@ -372,7 +377,8 @@ export const zh: Translations = { toolViewTitle: '工具调用显示', toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。', uiScaleTitle: '界面缩放', - uiScaleDesc: (percent: number) => `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, + uiScaleDesc: (percent: number) => + `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, translucencyTitle: '窗口透明', translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', embedsTitle: '内嵌预览', @@ -719,11 +725,43 @@ export const zh: Translations = { profileConnection: profile => `仅当“${profile}”是当前 profile 时使用此连接。设为本地即可继承默认连接。`, envOverrideTitle: '环境变量正在控制此桌面会话。', envOverrideDesc: '取消设置 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 后才会使用下面保存的设置。', + modeTitle: '连接模式', localTitle: '本地网关', localDesc: '在 localhost 启动私有 Hermes 后端。这是默认方式,并且可离线工作。', remoteTitle: '远程网关', - remoteDesc: - '将此桌面外壳连接到远程 Hermes 后端。托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。', + remoteDesc: '将此桌面外壳连接到远程 Hermes 后端。', + remoteAuthHint: '托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。', + cloudTitle: 'Hermes Cloud', + cloudDesc: '只需登录 Hermes Cloud 一次,即可从你账户下的智能体中选择——无需粘贴 URL。', + cloudSignInTitle: 'Hermes Cloud', + cloudSignIn: '登录 Hermes Cloud', + cloudSignedIn: '已登录 Hermes Cloud', + cloudNeedsSignIn: '登录 Hermes Cloud 以发现你账户下的智能体。', + cloudSignedInDesc: '你已登录。在下方选择一个智能体;会话会自动刷新。', + cloudAgentsTitle: '你的智能体', + cloudOrgPickerTitle: '选择一个组织', + cloudOrgSelect: '选择', + cloudOrgChange: '切换组织', + cloudOrgRole: role => `角色:${role}`, + cloudLoadingAgents: '正在加载你的智能体…', + cloudNoAgents: { + before: '此账户下未找到智能体。请在', + linkText: 'Nous 门户', + after: '中创建一个,然后刷新。' + }, + cloudRefresh: '刷新', + cloudConnect: '连接', + cloudConnecting: '正在连接…', + cloudDiscoverFailed: '无法加载你的 Hermes Cloud 智能体', + cloudConnectFailed: '无法连接到该智能体', + cloudSignInFailed: 'Hermes Cloud 登录失败', + cloudSignedOutTitle: '已退出 Hermes Cloud', + cloudSignedOutMessage: '已清除 Hermes Cloud 会话。', + cloudConnectedTitle: '已连接', + cloudConnectedPill: '已连接', + cloudConnectedTo: name => `已连接到 ${name}。`, + cloudAgentProvisioning: '正在配置…', + cloudStatusLabel: status => `状态:${status}`, remoteUrlTitle: '远程 URL', remoteUrlDesc: '远程 dashboard 后端的基础 URL。支持路径前缀,例如 /hermes。', probing: '正在检查此网关的认证方式…', @@ -756,7 +794,7 @@ export const zh: Translations = { enterUrlFirst: '请先输入远程 URL。', restartingTitle: '网关连接正在重启', savedTitle: '网关设置已保存', - restartingMessage: 'Hermes Desktop 将使用已保存设置重新连接。', + restartingMessage: 'Hermes Desktop 将使用已保存设置重新连接(界面保持打开)。', savedMessage: '已保存,下一次重启生效。', connectedTo: (baseUrl, version) => `已连接到 ${baseUrl}${version ? ` · Hermes ${version}` : ''}`, reachableTitle: '远程网关可访问', @@ -857,6 +895,9 @@ export const zh: Translations = { change: '更改', autoUseMain: '自动 · 使用主模型', providerDefault: '(提供方默认)', + fallbackAdd: '添加备用模型', + fallbackEmpty: '未配置备用模型 — 默认模型失败时才会使用备用模型。', + notInCatalog: '不在该提供方的模型列表中 — 调用可能回退到备用模型。', tasks: { vision: { label: '视觉', hint: '图片分析' }, web_extract: { label: '网页提取', hint: '页面总结' }, @@ -1632,7 +1673,10 @@ export const zh: Translations = { customPlaceholder: '0 9 * * * 或 weekdays at 9am', customHint: 'Cron 表达式,或类似"每小时""工作日上午 9 点"的短语。', optional: '可选', + promptRequired: '提示词为必填项。', promptScheduleRequired: '提示词和排程为必填项。', + scheduleRequired: '排程为必填项。', + scriptOnlyEditHint: '仅脚本任务(无 AI 提示词)。任务 ID:', saveChanges: '保存更改', createAction: '创建定时任务' }, @@ -1735,6 +1779,9 @@ export const zh: Translations = { newWorktreeTitle: '新建工作树', newWorktreeDesc: '为这个工作树命名分支。', branchPlaceholder: '例如 my-feature', + branchOff: () => ({ after: ' 分支', before: '从 ' }), + baseBranchPlaceholder: '搜索分支…', + baseBranchNone: '未找到分支', startWorkFailed: '无法创建工作树', convertBranch: '转换分支…', convertBranchTitle: '转换分支', @@ -2094,6 +2141,7 @@ export const zh: Translations = { connectFailed: '无法连接到本地服务器。' }, apiKeyOptions: { + fireworks: { short: '直接模型 API', description: '直接访问 Fireworks AI 托管的模型。' }, openrouter: { short: '一个密钥,多个模型', description: '用一个密钥访问数百个模型。适合新安装的默认选择。' }, openai: { short: 'GPT 级模型', description: '直接访问 OpenAI 模型。' }, gemini: { short: 'Gemini 模型', description: '直接访问 Google Gemini 模型。' }, @@ -2193,7 +2241,9 @@ export const zh: Translations = { low: '低', medium: '中', high: '高', + xhigh: '极高', max: '最高', + ultra: '超高', updateFailed: '模型选项更新失败', fastFailed: '快速模式更新失败' }, @@ -2212,6 +2262,16 @@ export const zh: Translations = { viewAllLogs: '查看全部日志 →', messagingPlatforms: '消息平台' }, + approvalMode: { + title: '审批模式', + ariaLabel: mode => `审批模式:${mode}`, + manual: '手动', + manualDescription: '执行需要审批的操作前询问', + smart: '智能', + smartDescription: '自动评估操作,并在需要时询问', + off: '关闭', + offDescription: '不显示审批提示,直接运行' + }, statusbar: { unknown: '未知', restart: '重启', @@ -2466,6 +2526,7 @@ export const zh: Translations = { other: '其他 (输入你的答案)', placeholder: '输入你的答案…', skip: '跳过', + skipped: '已跳过', continueLabel: '继续' }, tool: { diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index c6829420a1d..c622b90d85e 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -46,6 +46,7 @@ export type GatewayEventPayload = { reasoning_effort?: string service_tier?: string fast?: boolean + approval_mode?: string yolo?: boolean running?: boolean cwd?: string @@ -66,6 +67,7 @@ export type GatewayEventPayload = { description?: string // False when a tirith content-security warning forbids a permanent allow. allow_permanent?: boolean + smart_denied?: boolean // secret.request (skill credential capture) env_var?: string prompt?: string diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 06d8e4c3265..d315617d2ac 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -383,3 +383,61 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage { } } as ThreadMessage } + +export type ToolMergeCache = WeakMap< + ChatMessage, + { merged: ChatMessage; parts: ChatMessagePart[]; prev: ChatMessage; prevParts: ChatMessagePart[] } +> + +export function createToolMergeCache(): ToolMergeCache { + return new WeakMap() +} + +// A settled assistant message with only tool calls — no prose, no reasoning. +// The model routinely emits a follow-up batch of calls as its own text-less +// message; on screen it looks like one continuous run, but assistant-ui can't +// group tool calls across a message boundary. +function isToolOnlyAssistant(message: ChatMessage): boolean { + return ( + message.role === 'assistant' && + !message.pending && + !message.error && + !message.hidden && + message.parts.length > 0 && + message.parts.every(part => part.type === 'tool-call') + ) +} + +/** + * Fold each settled tool-only assistant message into the preceding assistant + * message so its calls join that message's tool group (and can collapse into + * the auto-scrolling window). Render-only — never mutates the `$messages` store + * — and settle-only: pending messages are left alone, so a live turn is never + * merged/un-merged mid-stream. `cache` keys merged results by source identity, + * so a stable turn yields stable merged objects (no re-render churn). + */ +export function coalesceToolOnlyAssistants(messages: ChatMessage[], cache: ToolMergeCache): ChatMessage[] { + const out: ChatMessage[] = [] + + for (const message of messages) { + const prev = out.at(-1) + + if (prev && prev.role === 'assistant' && !prev.pending && !prev.hidden && isToolOnlyAssistant(message)) { + const cached = cache.get(message) + + const merged = + cached && cached.prev === prev && cached.prevParts === prev.parts && cached.parts === message.parts + ? cached.merged + : { ...prev, parts: [...prev.parts, ...message.parts] } + + cache.set(message, { merged, parts: message.parts, prev, prevParts: prev.parts }) + out[out.length - 1] = merged + + continue + } + + out.push(message) + } + + return out +} diff --git a/apps/desktop/src/lib/desktop-git.ts b/apps/desktop/src/lib/desktop-git.ts index 1a12c3b8abe..ddc14df84da 100644 --- a/apps/desktop/src/lib/desktop-git.ts +++ b/apps/desktop/src/lib/desktop-git.ts @@ -1,4 +1,5 @@ import type { + HermesGitBaseBranch, HermesGitBranch, HermesGitWorktree, HermesRepoStatus, @@ -58,6 +59,9 @@ const remoteGit: GitBridge = { branchList: async repoPath => (await gitGet<{ branches: HermesGitBranch[] }>('branches', { path: repoPath })).branches, + baseBranchList: async repoPath => + (await gitGet<{ branches: HermesGitBaseBranch[] }>('base-branches', { path: repoPath })).branches, + repoStatus: repoPath => gitGet<HermesRepoStatus | null>('status', { path: repoPath }), fileDiff: async (repoPath, filePath) => diff --git a/apps/desktop/src/lib/gateway-events.test.ts b/apps/desktop/src/lib/gateway-events.test.ts index d51a943611f..7435d22d6ee 100644 --- a/apps/desktop/src/lib/gateway-events.test.ts +++ b/apps/desktop/src/lib/gateway-events.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { gatewayEventRequiresSessionId } from './gateway-events' +import { gatewayEventRequiresSessionId, resolveGatewayEventSessionId } from './gateway-events' describe('gateway event routing', () => { it('drops only unscoped subagent events (genuinely background work)', () => { @@ -24,4 +24,75 @@ describe('gateway event routing', () => { expect(gatewayEventRequiresSessionId('session.info')).toBe(false) expect(gatewayEventRequiresSessionId(undefined)).toBe(false) }) + + it('keeps unscoped stream events pinned to the session that started them', () => { + const started = resolveGatewayEventSessionId({ + activeSessionId: 'session-a', + eventType: 'message.start', + explicitSessionId: '', + unscopedStreamSessionId: null + }) + + expect(started).toEqual({ + drop: false, + nextUnscopedStreamSessionId: 'session-a', + sessionId: 'session-a' + }) + + const delta = resolveGatewayEventSessionId({ + activeSessionId: 'session-b', + eventType: 'message.delta', + explicitSessionId: '', + unscopedStreamSessionId: started.nextUnscopedStreamSessionId + }) + + expect(delta).toEqual({ + drop: false, + nextUnscopedStreamSessionId: 'session-a', + sessionId: 'session-a' + }) + + const completed = resolveGatewayEventSessionId({ + activeSessionId: 'session-b', + eventType: 'message.complete', + explicitSessionId: '', + unscopedStreamSessionId: delta.nextUnscopedStreamSessionId + }) + + expect(completed).toEqual({ + drop: false, + nextUnscopedStreamSessionId: null, + sessionId: 'session-a' + }) + }) + + it('routes a new unscoped stream start to the currently active session', () => { + const routed = resolveGatewayEventSessionId({ + activeSessionId: 'session-b', + eventType: 'message.start', + explicitSessionId: '', + unscopedStreamSessionId: 'session-a' + }) + + expect(routed).toEqual({ + drop: false, + nextUnscopedStreamSessionId: 'session-b', + sessionId: 'session-b' + }) + }) + + it('keeps explicit events scoped and clears a matching pinned stream on completion', () => { + const routed = resolveGatewayEventSessionId({ + activeSessionId: 'session-b', + eventType: 'message.complete', + explicitSessionId: 'session-a', + unscopedStreamSessionId: 'session-a' + }) + + expect(routed).toEqual({ + drop: false, + nextUnscopedStreamSessionId: null, + sessionId: 'session-a' + }) + }) }) diff --git a/apps/desktop/src/lib/gateway-events.ts b/apps/desktop/src/lib/gateway-events.ts index 673d1df8c6d..7871848c091 100644 --- a/apps/desktop/src/lib/gateway-events.ts +++ b/apps/desktop/src/lib/gateway-events.ts @@ -11,6 +11,34 @@ function asRecord(payload: unknown): Record<string, unknown> { return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {} } +/** + * Unscoped stream events that must stay pinned to the session that received + * ``message.start`` after the user switches chats mid-turn (#47709 / #48281). + * Without this, ``explicitSid || activeSessionId`` reattributes live deltas to + * the newly focused chat. + */ +const UNSCOPED_STREAM_EVENT_TYPES = new Set([ + 'approval.request', + 'browser.progress', + 'clarify.request', + 'error', + 'message.complete', + 'message.delta', + 'message.start', + 'reasoning.available', + 'reasoning.delta', + 'secret.request', + 'status.update', + 'sudo.request', + 'thinking.delta', + 'tool.complete', + 'tool.generating', + 'tool.progress', + 'tool.start' +]) + +const UNSCOPED_STREAM_END_EVENT_TYPES = new Set(['error', 'message.complete']) + /** * Whether an unscoped event (no `session_id`) must be dropped rather than * attributed to the focused chat. @@ -27,6 +55,71 @@ export function gatewayEventRequiresSessionId(eventType: string | undefined): bo return eventType?.startsWith('subagent.') ?? false } +export interface GatewayEventSessionRouteInput { + activeSessionId: null | string + eventType: string | undefined + explicitSessionId: string + unscopedStreamSessionId: null | string +} + +export interface GatewayEventSessionRoute { + drop: boolean + nextUnscopedStreamSessionId: null | string + sessionId: null | string +} + +/** + * Resolve which runtime session owns a gateway event. + * + * Explicit ``session_id`` always wins. Unscoped stream events pin to the + * session that received ``message.start`` so a mid-turn chat switch cannot + * steal live deltas / tool events onto the newly focused transcript. + */ +export function resolveGatewayEventSessionId({ + activeSessionId, + eventType, + explicitSessionId, + unscopedStreamSessionId +}: GatewayEventSessionRouteInput): GatewayEventSessionRoute { + if (explicitSessionId) { + const nextUnscopedStreamSessionId = + eventType && UNSCOPED_STREAM_END_EVENT_TYPES.has(eventType) && explicitSessionId === unscopedStreamSessionId + ? null + : unscopedStreamSessionId + + return { + drop: false, + nextUnscopedStreamSessionId, + sessionId: explicitSessionId + } + } + + if (gatewayEventRequiresSessionId(eventType)) { + return { + drop: true, + nextUnscopedStreamSessionId: unscopedStreamSessionId, + sessionId: null + } + } + + const streamEvent = eventType ? UNSCOPED_STREAM_EVENT_TYPES.has(eventType) : false + const sessionId = + eventType === 'message.start' ? activeSessionId : streamEvent ? unscopedStreamSessionId || activeSessionId : activeSessionId + let nextUnscopedStreamSessionId = unscopedStreamSessionId + + if (eventType === 'message.start' && activeSessionId) { + nextUnscopedStreamSessionId = activeSessionId + } else if (eventType && UNSCOPED_STREAM_END_EVENT_TYPES.has(eventType)) { + nextUnscopedStreamSessionId = null + } + + return { + drop: false, + nextUnscopedStreamSessionId, + sessionId + } +} + export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean { if (event.type !== 'tool.complete') { return false diff --git a/apps/desktop/src/lib/gateway-rpc.test.ts b/apps/desktop/src/lib/gateway-rpc.test.ts index 6da30b87b76..6c84c12ecaa 100644 --- a/apps/desktop/src/lib/gateway-rpc.test.ts +++ b/apps/desktop/src/lib/gateway-rpc.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/lib/gateway-rpc.ts b/apps/desktop/src/lib/gateway-rpc.ts index a209aefbd00..6cf298402f3 100644 --- a/apps/desktop/src/lib/gateway-rpc.ts +++ b/apps/desktop/src/lib/gateway-rpc.ts @@ -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`) +} diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index e863aa39280..b96d21e0c2c 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -27,6 +27,7 @@ import { IconCircle as CircleIcon, IconClipboard as Clipboard, IconClock as Clock, + IconCloud as Cloud, IconCommand as Command, IconCopy as Copy, IconCopy as CopyIcon, @@ -58,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, @@ -140,8 +142,10 @@ export { ChevronRight, ChevronRightIcon, CircleIcon, + CircleLetterA, Clipboard, Clock, + Cloud, Command, Copy, CopyIcon, diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts index e8dfd35c7f6..ffaea2c7c2b 100644 --- a/apps/desktop/src/lib/media.ts +++ b/apps/desktop/src/lib/media.ts @@ -79,7 +79,7 @@ export function mediaExternalUrl(path: string): string { return /^file:/i.test(path) ? path : `file://${path}` } -// Custom Electron scheme (registered in electron/main.cjs) that streams a local +// Custom Electron scheme (registered in electron/main.ts) that streams a local // file with Range support. Used for audio/video so playback bypasses the data // URL size cap and supports seeking. `path` may be a plain path or `file://…`. export function mediaStreamUrl(path: string): string { diff --git a/apps/desktop/src/lib/model-options.test.ts b/apps/desktop/src/lib/model-options.test.ts index a1f6c057ed9..65bc180a7f8 100644 --- a/apps/desktop/src/lib/model-options.test.ts +++ b/apps/desktop/src/lib/model-options.test.ts @@ -24,7 +24,7 @@ describe('requestModelOptions', () => { await expect(requestModelOptions({ gateway: gateway as never, sessionId: null })).resolves.toBe(gatewayPayload) - expect(gateway.request).toHaveBeenCalledWith('model.options', {}) + expect(gateway.request).toHaveBeenCalledWith('model.options', { explicit_only: true }) expect(getGlobalModelOptions).not.toHaveBeenCalled() }) @@ -36,6 +36,7 @@ describe('requestModelOptions', () => { await requestModelOptions({ gateway: gateway as never, refresh: true, sessionId: 'session-1' }) expect(gateway.request).toHaveBeenCalledWith('model.options', { + explicit_only: true, refresh: true, session_id: 'session-1' }) @@ -44,6 +45,6 @@ describe('requestModelOptions', () => { it('falls back to REST when no gateway is connected', async () => { await requestModelOptions({ refresh: true }) - expect(getGlobalModelOptions).toHaveBeenCalledWith({ refresh: true }) + expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true, refresh: true }) }) }) diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts index 4e22d9fb974..cc499c86d56 100644 --- a/apps/desktop/src/lib/model-status-label.test.ts +++ b/apps/desktop/src/lib/model-status-label.test.ts @@ -22,7 +22,9 @@ describe('model-status-label', () => { it('maps reasoning effort to compact labels', () => { expect(reasoningEffortLabel('high')).toBe('High') - expect(reasoningEffortLabel('xhigh')).toBe('Max') + expect(reasoningEffortLabel('xhigh')).toBe('XHigh') + expect(reasoningEffortLabel('max')).toBe('Max') + expect(reasoningEffortLabel('ultra')).toBe('Ultra') expect(reasoningEffortLabel('')).toBe('') }) diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts index 27a4d202b7d..a1b28b8a4c5 100644 --- a/apps/desktop/src/lib/model-status-label.ts +++ b/apps/desktop/src/lib/model-status-label.ts @@ -6,7 +6,9 @@ const REASONING_LABELS: Record<string, string> = { low: 'Low', medium: 'Med', high: 'High', - xhigh: 'Max' + xhigh: 'XHigh', + max: 'Max', + ultra: 'Ultra' } export function reasoningEffortLabel(effort: string): string { diff --git a/apps/desktop/src/store/approval-mode.test.ts b/apps/desktop/src/store/approval-mode.test.ts new file mode 100644 index 00000000000..5a15f6c6ff3 --- /dev/null +++ b/apps/desktop/src/store/approval-mode.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + $approvalModes, + approvalModeForProfile, + reconcileApprovalModeForProfile, + setApprovalModeForProfile, + syncApprovalModeForProfile +} from './approval-mode' + +function deferred<T>() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + + const promise = new Promise<T>((res, rej) => { + resolve = res + reject = rej + }) + + return { promise, reject, resolve } +} + +describe('profile-scoped approval mode cache', () => { + beforeEach(() => $approvalModes.set({})) + + it('labels an unread profile Smart by default and adopts backend truth', async () => { + expect(approvalModeForProfile('default')).toBe('smart') + + const request = vi.fn(async () => ({ value: 'manual' })) + await syncApprovalModeForProfile(request, 'default') + + expect(request).toHaveBeenCalledWith('config.get', { key: 'approvals.mode' }) + expect(approvalModeForProfile('default')).toBe('manual') + }) + + it('keeps profile values isolated', async () => { + await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'manual' })), 'work') + await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'off' })), 'personal') + + expect(approvalModeForProfile('work')).toBe('manual') + expect(approvalModeForProfile('personal')).toBe('off') + expect(approvalModeForProfile('default')).toBe('smart') + }) + + it('rolls consecutive failed writes back to the last authoritative value', async () => { + await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'smart' })), 'default') + const first = deferred<{ value: string }>() + const second = deferred<{ value: string }>() + + const request = vi + .fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + + const staleWrite = setApprovalModeForProfile(request, 'default', 'manual') + const currentWrite = setApprovalModeForProfile(request, 'default', 'off') + expect(approvalModeForProfile('default')).toBe('off') + + first.reject(new Error('old failure')) + await expect(staleWrite).rejects.toThrow('old failure') + expect(approvalModeForProfile('default')).toBe('off') + + second.reject(new Error('current failure')) + await expect(currentWrite).rejects.toThrow('current failure') + expect(approvalModeForProfile('default')).toBe('smart') + }) + + it('lets a backend event supersede an optimistic write and its later failure', async () => { + const write = deferred<{ value: string }>() + const pending = setApprovalModeForProfile(vi.fn(() => write.promise), 'work', 'off') + + reconcileApprovalModeForProfile('work', 'smart') + expect(approvalModeForProfile('work')).toBe('smart') + + write.reject(new Error('late failure')) + await expect(pending).rejects.toThrow('late failure') + expect(approvalModeForProfile('work')).toBe('smart') + }) + + it('ignores a stale initial read after a newer write succeeds', async () => { + const read = deferred<{ value: string }>() + + const request = vi + .fn() + .mockImplementationOnce(() => read.promise) + .mockResolvedValueOnce({ value: 'off' }) + + const staleRead = syncApprovalModeForProfile(request, 'default') + await setApprovalModeForProfile(request, 'default', 'off') + read.resolve({ value: 'manual' }) + await staleRead + + expect(approvalModeForProfile('default')).toBe('off') + }) +}) diff --git a/apps/desktop/src/store/approval-mode.ts b/apps/desktop/src/store/approval-mode.ts new file mode 100644 index 00000000000..1000f208d95 --- /dev/null +++ b/apps/desktop/src/store/approval-mode.ts @@ -0,0 +1,100 @@ +import { atom } from 'nanostores' + +export type ApprovalMode = 'manual' | 'off' | 'smart' +export type ApprovalModeRequester = ( + method: string, + params?: Record<string, unknown> +) => Promise<unknown> + +const APPROVAL_MODES = new Set<ApprovalMode>(['manual', 'smart', 'off']) +const revisions = new Map<string, number>() +const confirmedModes = new Map<string, ApprovalMode>() + +export const $approvalModes = atom<Record<string, ApprovalMode>>({}) + +function profileKey(profile: string): string { + return profile.trim() || 'default' +} + +function nextRevision(profile: string): number { + const revision = (revisions.get(profile) ?? 0) + 1 + revisions.set(profile, revision) + + return revision +} + +function normalizeApprovalMode(value: unknown): ApprovalMode { + const normalized = String(value ?? '') + .trim() + .toLowerCase() as ApprovalMode + + return APPROVAL_MODES.has(normalized) ? normalized : 'manual' +} + +export function approvalModeForProfile(profile: string): ApprovalMode { + return $approvalModes.get()[profileKey(profile)] ?? 'smart' +} + +function cacheApprovalMode(profile: string, mode: ApprovalMode): void { + const key = profileKey(profile) + $approvalModes.set({ ...$approvalModes.get(), [key]: mode }) +} + +export function reconcileApprovalModeForProfile(profile: string, value: unknown): ApprovalMode { + const key = profileKey(profile) + const mode = normalizeApprovalMode(value) + nextRevision(key) + confirmedModes.set(key, mode) + cacheApprovalMode(key, mode) + + return mode +} + +export async function syncApprovalModeForProfile( + requestGateway: ApprovalModeRequester, + profile: string +): Promise<ApprovalMode> { + const key = profileKey(profile) + const revision = nextRevision(key) + const result = (await requestGateway('config.get', { key: 'approvals.mode' })) as { value?: string } + const mode = normalizeApprovalMode(result?.value) + + if (revisions.get(key) === revision) { + confirmedModes.set(key, mode) + cacheApprovalMode(key, mode) + } + + return mode +} + +export async function setApprovalModeForProfile( + requestGateway: ApprovalModeRequester, + profile: string, + mode: ApprovalMode +): Promise<ApprovalMode> { + const key = profileKey(profile) + const revision = nextRevision(key) + cacheApprovalMode(key, mode) + + try { + const result = (await requestGateway('config.set', { + key: 'approvals.mode', + value: mode + })) as { value?: string } + + const authoritative = normalizeApprovalMode(result?.value) + + if (revisions.get(key) === revision) { + confirmedModes.set(key, authoritative) + cacheApprovalMode(key, authoritative) + } + + return authoritative + } catch (error) { + if (revisions.get(key) === revision) { + cacheApprovalMode(key, confirmedModes.get(key) ?? 'smart') + } + + throw error + } +} diff --git a/apps/desktop/src/store/gateway-switch.test.ts b/apps/desktop/src/store/gateway-switch.test.ts new file mode 100644 index 00000000000..5b24c120ccc --- /dev/null +++ b/apps/desktop/src/store/gateway-switch.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $sessionsLimit, resetSessionsLimit, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout' +import { + $cronSessions, + $freshDraftReady, + $messagingSessions, + $sessions, + $sessionsLoading, + $sessionsTotal, + setCronSessions, + setFreshDraftReady, + setMessagingSessions, + setSessions, + setSessionsLoading, + setSessionsTotal +} from '@/store/session' + +import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from './gateway-switch' + +vi.mock('@/lib/query-client', () => ({ + queryClient: { invalidateQueries: vi.fn() } +})) + +describe('wipeSessionListsForGatewaySwitch', () => { + beforeEach(() => { + $gatewaySwitching.set(false) + setSessions([{ id: 's1', title: 'old', profile: 'default' } as never]) + setSessionsTotal(1) + setCronSessions([{ id: 'c1', title: 'cron', profile: 'default' } as never]) + setMessagingSessions([{ id: 'm1', title: 'tg', profile: 'default' } as never]) + setSessionsLoading(false) + setFreshDraftReady(false) + $sessionsLimit.set(SIDEBAR_SESSIONS_PAGE_SIZE * 3) + }) + + afterEach(() => { + resetSessionsLimit() + setSessions([]) + setCronSessions([]) + setMessagingSessions([]) + setSessionsLoading(true) + $gatewaySwitching.set(false) + }) + + it('clears lists and arms loading so sidebar skeletons retrigger', () => { + wipeSessionListsForGatewaySwitch() + + expect($sessions.get()).toEqual([]) + expect($sessionsTotal.get()).toBe(0) + expect($cronSessions.get()).toEqual([]) + expect($messagingSessions.get()).toEqual([]) + expect($sessionsLoading.get()).toBe(true) + expect($sessionsLimit.get()).toBe(SIDEBAR_SESSIONS_PAGE_SIZE) + expect($freshDraftReady.get()).toBe(true) + }) +}) diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts new file mode 100644 index 00000000000..27f9f952f67 --- /dev/null +++ b/apps/desktop/src/store/gateway-switch.ts @@ -0,0 +1,58 @@ +import { atom } from 'nanostores' + +import { queryClient } from '@/lib/query-client' +import { resetSessionsLimit } from '@/store/layout' +import { + setActiveSessionId, + setAttentionSessionIds, + setCronSessions, + setFreshDraftReady, + setMessages, + setMessagingPlatformTotals, + setMessagingSessions, + setMessagingTruncated, + setSelectedStoredSessionId, + setSessionProfileTotals, + setSessions, + setSessionsLoading, + setSessionsTotal, + setWorkingSessionIds +} from '@/store/session' + +// True while a soft gateway-mode apply is mid-flight (wipe → re-dial). Lets the +// boot hook suppress the backend-exit toast and keeps the cold-boot CONNECTING +// overlay from resurrecting when startHermes re-emits boot progress. +export const $gatewaySwitching = atom(false) + +/** + * Clear gateway-bound session UI so sidebar skeletons retrigger. + * + * Sessions live in nanostores (not React Query) — refreshSessions merges into + * the existing list, so without an explicit wipe a soft switch would keep + * painting the previous gateway's rows. RQ caches (settings/config/skills) are + * invalidated separately; the live session list is this path. + * + * Does NOT call requestFreshSession() — that navigates to NEW_CHAT and would + * close route overlays (Settings). Clear chat state in place; leave the URL + * alone so the user stays where they were (e.g. mid-Gateway settings). + */ +export function wipeSessionListsForGatewaySwitch(): void { + setSessions([]) + setSessionsTotal(0) + setSessionProfileTotals({}) + setCronSessions([]) + setMessagingSessions([]) + setMessagingPlatformTotals({}) + setMessagingTruncated(false) + setWorkingSessionIds([]) + setAttentionSessionIds([]) + setSessionsLoading(true) + resetSessionsLimit() + + setActiveSessionId(null) + setSelectedStoredSessionId(null) + setMessages([]) + setFreshDraftReady(true) + + void queryClient.invalidateQueries() +} diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index ee51119dd78..04c024c22e4 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -164,7 +164,7 @@ function createSecondary(profile: string): Secondary { wantOpen: true } - entry.offEvent = gateway.onEvent(event => config?.onEvent(event)) + entry.offEvent = gateway.onEvent(event => config?.onEvent({ ...event, profile })) entry.offState = gateway.onState(state => { reportGatewayState(profile, state) diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 17e9964cc81..0bebec57e66 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -284,7 +284,7 @@ describe('OAuth onboarding', () => { return { ok: true, status: 'approved' } } - if (path === '/api/model/options') { + if (path.startsWith('/api/model/options')) { return { providers: [ { @@ -357,7 +357,7 @@ describe('OAuth onboarding', () => { expect(calls.some(c => c.path === '/api/model/set')).toBe(true) - const optionsIndex = calls.findIndex(c => c.path === '/api/model/options') + const optionsIndex = calls.findIndex(c => c.path.startsWith('/api/model/options')) const recommendedIndex = calls.findIndex(c => c.path.startsWith('/api/model/recommended-default')) const setIndex = calls.findIndex(c => c.path === '/api/model/set') diff --git a/apps/desktop/src/store/panes.test.ts b/apps/desktop/src/store/panes.test.ts index 6986ae27711..8ed2fe3723b 100644 --- a/apps/desktop/src/store/panes.test.ts +++ b/apps/desktop/src/store/panes.test.ts @@ -97,14 +97,14 @@ describe('panes store', () => { expect(getPaneStateSnapshot('files')?.widthOverride).toBeUndefined() }) - it('width override is in-memory only — not persisted across reloads', () => { + it('width override is NOT in-memory only, and is persisted across reloads', () => { ensurePaneRegistered('files', { open: true }) setPaneWidthOverride('files', 300) const persisted = window.localStorage.getItem(STORAGE_KEY) expect(persisted).not.toBeNull() - expect(JSON.parse(persisted ?? '{}')).toEqual({ files: { open: true } }) + expect(JSON.parse(persisted ?? '{}')).toEqual({ files: { open: true, widthOverride: 300 } }) }) it('open flag is persisted across changes', () => { diff --git a/apps/desktop/src/store/pet-overlay.ts b/apps/desktop/src/store/pet-overlay.ts index 1ab1b64ad23..62fb3f4ba37 100644 --- a/apps/desktop/src/store/pet-overlay.ts +++ b/apps/desktop/src/store/pet-overlay.ts @@ -8,7 +8,7 @@ import { $awaitingResponse, $busy } from '@/store/session' * Controller for the pop-out pet overlay (main-renderer side). * * Shift-clicking the in-window pet "pops it out" into a transparent, - * always-on-top OS window (created in electron/main.cjs) that can leave the + * always-on-top OS window (created in electron/main.ts) that can leave the * app's bounds and stays visible while Hermes is minimized. That window carries * NO gateway connection — this renderer remains the single source of truth and * pushes the live pet state to it over IPC. Control flows back (pop the pet back @@ -30,7 +30,7 @@ export interface PetOverlayBounds { /** * Request to open the overlay window. `screen` says whether `bounds` are already * in absolute screen coordinates (a remembered/dragged spot) or in the main - * window's viewport space (a fresh shift-click pop-out, which main.cjs converts + * window's viewport space (a fresh shift-click pop-out, which main.ts converts * by adding the content origin). */ export interface PetOverlayOpenRequest { @@ -46,6 +46,8 @@ export interface PetOverlayStatePayload { awaiting: boolean /** Drives the overlay's mail icon: a finish landed while you were away. */ unread: boolean + /** Latest reaction — bumping its id forwards a burst to the overlay. */ + reaction: PetReaction | null } export type PetOverlayControl = @@ -67,6 +69,22 @@ export const $petOverlayActive = atom(storedBoolean(OVERLAY_ACTIVE_KEY, false)) // Persist the in/out choice so a popped-out pet comes back popped out. $petOverlayActive.subscribe(active => persistBoolean(OVERLAY_ACTIVE_KEY, active)) +/** + * Reaction signal forwarded to the popped-out overlay window via the state + * mirror below. `id` is a monotonic nonce so the overlay fires once per bump; + * `kind` selects the renderer (today only `vibe` → hearts). Generic on purpose + * so future reactions (emoji, etc.) ride the same channel. + */ +export interface PetReaction { + id: number + kind: string +} + +export const $petReaction = atom<PetReaction | null>(null) + +export const forwardPetReaction = (kind: string) => + $petReaction.set({ id: ($petReaction.get()?.id ?? 0) + 1, kind }) + function loadSavedBounds(): null | PetOverlayBounds { try { const raw = storedString(OVERLAY_BOUNDS_KEY) @@ -129,7 +147,8 @@ function currentPayload(): PetOverlayStatePayload { activity: $petActivity.get(), busy: $busy.get(), awaiting: $awaitingResponse.get(), - unread: $petUnread.get() + unread: $petUnread.get(), + reaction: $petReaction.get() } } @@ -165,14 +184,15 @@ function openOverlay(request: PetOverlayOpenRequest): void { $petActivity.subscribe(pushNow), $busy.subscribe(pushNow), $awaitingResponse.subscribe(pushNow), - $petUnread.subscribe(pushNow) + $petUnread.subscribe(pushNow), + $petReaction.subscribe(pushNow) ] } /** * Pop the pet out of the window. `petRect` is the in-window sprite's viewport * rect; we grow it to the padded overlay size and center the window on the - * pet's old spot (main.cjs adds the window's screen origin). If the user has + * pet's old spot (main.ts adds the window's screen origin). If the user has * popped out before, reopen at that remembered desktop spot instead. */ export function popOutPet(petRect: PetOverlayBounds): void { @@ -273,7 +293,7 @@ export function initPetOverlayBridge(): () => void { // overlay on the next push, keeping both surfaces in sync. scaleHandler?.(payload.scale) } else if (payload?.type === 'open-app') { - // Mail icon: surface the app on the most recent thread (main.cjs already + // Mail icon: surface the app on the most recent thread (main.ts already // focused the window before forwarding this) and mark it read. clearPetUnread() openAppHandler?.() diff --git a/apps/desktop/src/store/pet.ts b/apps/desktop/src/store/pet.ts index 1b189ac8291..b1e2e9d214e 100644 --- a/apps/desktop/src/store/pet.ts +++ b/apps/desktop/src/store/pet.ts @@ -92,6 +92,9 @@ export function derivePetState(activity: PetActivity): PetState { export const $petInfo = atom<PetInfo>({ enabled: false }) export const $petActivity = atom<PetActivity>({}) +/** Pet installed + enabled with a loaded spritesheet (ready to show/react). */ +export const $petActive = computed($petInfo, info => info.enabled && Boolean(info.spritesheetBase64)) + /** * Profile the pet RPCs should resolve against. Pets are per-profile — the active * pet (`display.pet.*`) and the installed sprites live under each profile's diff --git a/apps/desktop/src/store/preview.test.ts b/apps/desktop/src/store/preview.test.ts index d5d4807ef53..d41a7d9e946 100644 --- a/apps/desktop/src/store/preview.test.ts +++ b/apps/desktop/src/store/preview.test.ts @@ -48,8 +48,8 @@ describe('preview store', () => { $previewServerRestart.set(null) $activeSessionId.set(null) $selectedStoredSessionId.set(null) - window.localStorage.clear() clearSessionPreviewRegistry() + window.localStorage.clear() }) it('does not notify status subscribers for restart progress text', () => { diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 869ca2cad0d..a78ac06ee1d 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -1,7 +1,7 @@ import { atom } from 'nanostores' import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups' -import type { HermesGitBranch } from '@/global' +import type { HermesGitBaseBranch, HermesGitBranch } from '@/global' import { translateNow } from '@/i18n' import { desktopDefaultCwd, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs' import { desktopGit } from '@/lib/desktop-git' @@ -709,6 +709,19 @@ export async function listRepoBranches(repoPath: string): Promise<HermesGitBranc return git.branchList(repoPath) } +// Local + remote-tracking branches for the base-branch picker in the +// new-worktree dialog. The remote default (origin/HEAD) is flagged so the +// UI can preselect it. Empty on a remote backend / non-repo. +export async function listBaseBranches(repoPath: string): Promise<HermesGitBaseBranch[]> { + const git = desktopGit() + + if (!git?.baseBranchList || !repoPath) { + return [] + } + + return git.baseBranchList(repoPath) +} + export async function switchBranchInRepo(repoPath: string, branch: string): Promise<void> { const git = desktopGit() diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts index 285abad10f1..cf857b59f36 100644 --- a/apps/desktop/src/store/prompts.ts +++ b/apps/desktop/src/store/prompts.ts @@ -71,8 +71,10 @@ function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> { export interface ApprovalRequest extends KeyedPrompt { // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow". allowPermanent?: boolean + choices?: string[] command: string description: string + smartDenied?: boolean } export interface SudoRequest extends KeyedPrompt { diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 2be40853054..91b27ca56af 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -42,6 +42,7 @@ function workspaceCwdKey(connection: HermesConnection | null = $connection.get() } export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || '' +export type NewChatWorkspaceTarget = null | string | undefined export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir @@ -270,6 +271,8 @@ export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false)) // reflection of the truth the gateway reports rather than its own store. export const $yoloActive = atom(false) export const $currentCwd = atom(getRememberedWorkspaceCwd()) +export const $newChatWorkspaceTarget = atom<NewChatWorkspaceTarget>(undefined) +export const $newChatWorkspaceTargetGeneration = atom(0) export const $currentBranch = atom('') export const $currentUsage = atom<UsageStats>({ calls: 0, @@ -338,6 +341,16 @@ export const setCurrentCwd = (next: Updater<string>) => { persistString(workspaceCwdKey(), $currentCwd.get().trim() || null) } +export const setCurrentCwdTransient = (next: Updater<string>) => updateAtom($currentCwd, next) + +export const setNewChatWorkspaceTarget = (next: NewChatWorkspaceTarget): number => { + const generation = $newChatWorkspaceTargetGeneration.get() + 1 + $newChatWorkspaceTarget.set(next) + $newChatWorkspaceTargetGeneration.set(generation) + + return generation +} + export const workspaceCwdForNewSession = (): string => { if ($connection.get()?.mode === 'remote') { return getRememberedWorkspaceCwd() diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index c2f5831bc55..7439a8a8345 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -120,7 +120,7 @@ describe('reportBackendContract', () => { }) it('dismisses the toast when the backend meets the contract', () => { - reportBackendContract(2) + reportBackendContract(3) expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew') expect(notifySpy).not.toHaveBeenCalled() }) @@ -160,8 +160,8 @@ describe('reportBackendContract', () => { lastToast().onDismiss() notifySpy.mockClear() - reportBackendContract(2) // backend updated → satisfied, snooze cleared - reportBackendContract(1) // a later regression must warn immediately + reportBackendContract(3) // backend updated → satisfied, snooze cleared + reportBackendContract(2) // a later regression must warn immediately expect(notifySpy).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index b07347fa88b..a7b0bbc8b94 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -91,7 +91,8 @@ function isUpdateToastSnoozed(): boolean { // against. The backend reports its own value in session runtime info; a lower // value (or none — a pre-GUI checkout) means GUI<->backend skew. // v2: requires the file.attach RPC (remote-gateway non-image file upload). -const REQUIRED_BACKEND_CONTRACT = 2 +// v3: requires approvals.mode config RPCs and session.info reconciliation. +const REQUIRED_BACKEND_CONTRACT = 3 const SKEW_TOAST_ID = 'backend-contract-skew' // The contract check runs on every session.resume (applyRuntimeInfo), so // without a snooze the warning re-popped on every thread the user opened, even diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index c5b36ca6855..a881aa4794c 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -1,7 +1,7 @@ import { notifyError } from './notifications' // Window flag set by the Electron main process when it opens a standalone -// session window (see electron/main.cjs buildSessionWindowUrl). It rides in the +// session window (see electron/main.ts buildSessionWindowUrl). It rides in the // query string BEFORE the HashRouter '#', so we read it from location.search, // never from the router. A "secondary" window renders a single chat without the // global session sidebar or the install / onboarding overlays. diff --git a/apps/desktop/src/store/zoom.ts b/apps/desktop/src/store/zoom.ts index 804f32c68a7..6fc867c3ddd 100644 --- a/apps/desktop/src/store/zoom.ts +++ b/apps/desktop/src/store/zoom.ts @@ -1,7 +1,7 @@ /** * Window text size (zoom). * - * The main process owns the zoom level and persists it (see electron/zoom.cjs + * The main process owns the zoom level and persists it (see electron/zoom.ts * for the scale). The renderer only mirrors the current percent for the * settings UI: preset clicks go to the main process over IPC, and every * change comes back through onChanged, including ones made with the diff --git a/apps/desktop/src/themes/install.ts b/apps/desktop/src/themes/install.ts index 0958a92a99e..933203e8dc6 100644 --- a/apps/desktop/src/themes/install.ts +++ b/apps/desktop/src/themes/install.ts @@ -2,7 +2,7 @@ * Install desktop themes from external sources. * * The heavy lifting (network + .vsix unzip) lives in the Electron main process - * (`electron/vscode-marketplace.cjs`), reached via `window.hermesDesktop.themes`. + * (`electron/vscode-marketplace.ts`), reached via `window.hermesDesktop.themes`. * Main hands back the raw theme JSON; we parse + convert + persist here so the * conversion stays in one unit-testable place. */ diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index e28e0283587..9f77d7bd5eb 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -380,6 +380,7 @@ export interface PaginatedSessions { export interface RpcEvent<T = unknown> { payload?: T + profile?: string session_id?: string type: string } @@ -470,6 +471,7 @@ export interface SessionResumeResponse { } export interface SessionRuntimeInfo { + approval_mode?: 'manual' | 'off' | 'smart' branch?: string config_warning?: string credential_warning?: string @@ -633,6 +635,7 @@ export interface CronJob { last_run_at?: null | string name?: null | string next_run_at?: null | string + no_agent?: boolean prompt?: null | string schedule?: CronJobSchedule schedule_display?: null | string diff --git a/apps/desktop/tsconfig.electron.json b/apps/desktop/tsconfig.electron.json new file mode 100644 index 00000000000..0d25682602b --- /dev/null +++ b/apps/desktop/tsconfig.electron.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "strict": false, + "noImplicitAny": false, + "noImplicitThis": false, + "strictNullChecks": false, + "strictFunctionTypes": false, + "strictBindCallApply": false, + "strictPropertyInitialization": false, + "noUncheckedIndexedAccess": false, + "exactOptionalPropertyTypes": false, + "ignoreDeprecations": "6.0", + "composite": true, + "declaration": true, + "outDir": "build/electron-types" + }, + "include": ["electron"], + "exclude": ["src"] +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 270dc9f126c..41a4b25a8b9 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2023", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2023"], + "types": ["node"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, @@ -13,7 +14,6 @@ "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, - "noEmit": true, "jsx": "react-jsx", "paths": { "@/*": ["./src/*"], @@ -21,5 +21,5 @@ } }, "include": ["src", "../shared/src"], - "references": [] + "references": [{ "path": "./tsconfig.electron.json" }] } diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts new file mode 100644 index 00000000000..de633561862 --- /dev/null +++ b/apps/desktop/vitest.config.ts @@ -0,0 +1,27 @@ +import type { TestProjectConfiguration } from 'vitest/config'; +import { defineConfig } from 'vitest/config' + +const reactUi: TestProjectConfiguration = { + extends: './vite.config.ts', + test: { + name: 'ui', + environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + globals: true + } +} + +const electronNative: TestProjectConfiguration = { + test: { + name: 'electron', + environment: 'node', + include: ['electron/**/*.test.ts', 'scripts/**.test.{ts,mjs}'] + } +} + +export default defineConfig({ + test: { + projects: [reactUi, electronNative] + } +}) diff --git a/apps/desktop/vitest.setup.ts b/apps/desktop/vitest.setup.ts new file mode 100644 index 00000000000..a671ac3ae97 --- /dev/null +++ b/apps/desktop/vitest.setup.ts @@ -0,0 +1,6 @@ +import '@testing-library/react' + +// React 19 + Testing Library 16: opt into the act environment so render(), +// fireEvent(), and findBy* queries automatically flush state updates without +// spurious "not wrapped in act(...)" warnings. +;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true diff --git a/apps/shared/package.json b/apps/shared/package.json index bd1c10a48a6..57b7f776bce 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -8,7 +8,8 @@ }, "types": "./src/index.ts", "scripts": { - "typecheck": "tsc -p . --noEmit" + "typecheck": "tsc -p . --noEmit", + "check": "npm run typecheck" }, "devDependencies": { "typescript": "^6.0.3" diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index 2cf4ed1dff4..4707ffd8f61 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -23,6 +23,8 @@ export type GatewayEventName = export interface GatewayEvent<P = unknown> { payload?: P + /** Renderer-side source tag added by the Desktop gateway registry. */ + profile?: string session_id?: string type: GatewayEventName } diff --git a/batch_runner.py b/batch_runner.py index 28936198955..f7fc28d7a5c 100644 --- a/batch_runner.py +++ b/batch_runner.py @@ -1192,7 +1192,7 @@ def main( providers_order (str): Comma-separated list of OpenRouter providers to try in order (e.g. "anthropic,openai,google") provider_sort (str): Sort providers by "price", "throughput", or "latency" (OpenRouter only) max_tokens (int): Maximum tokens for model responses (optional, uses model default if not set) - reasoning_effort (str): OpenRouter reasoning effort level: "none", "minimal", "low", "medium", "high", "xhigh" (default: "medium") + reasoning_effort (str): Reasoning effort: "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra" (default: "medium") reasoning_disabled (bool): Completely disable reasoning/thinking tokens (default: False) prefill_messages_file (str): Path to JSON file containing prefill messages (list of {role, content} dicts) max_samples (int): Only process the first N samples from the dataset (optional, processes all if not set) @@ -1261,7 +1261,7 @@ def main( print("🧠 Reasoning: DISABLED (effort=none)") elif reasoning_effort: # Use specified effort level - valid_efforts = ["none", "minimal", "low", "medium", "high", "xhigh"] + valid_efforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"] if reasoning_effort not in valid_efforts: print(f"❌ Error: --reasoning_effort must be one of: {', '.join(valid_efforts)}") return diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 69acc686805..1858f451c9a 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -28,6 +28,7 @@ model: # "xiaomi" - Xiaomi MiMo (requires: XIAOMI_API_KEY) # "arcee" - Arcee AI Trinity models (requires: ARCEEAI_API_KEY) # "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY — https://ollama.com/settings) + # "deepinfra" - DeepInfra (requires: DEEPINFRA_API_KEY) # "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY) # "azure-foundry" - Microsoft Foundry / Azure OpenAI (API key or Entra ID) # "lmstudio" - LM Studio local server (optional: LM_API_KEY, defaults to http://127.0.0.1:1234/v1) @@ -1010,6 +1011,34 @@ stt: model: "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe # mistral: # model: "voxtral-mini-latest" # voxtral-mini-latest | voxtral-mini-2602 + # deepinfra: + # # Model id is discovered live from the DeepInfra catalog filtered + # # by the `stt` surface tag — leave `model` blank to take the first + # # live result. Pin only when you need a specific Whisper variant. + # model: "" + +# Text-to-speech. Only the deepinfra block is documented here — the +# remaining providers (edge, openai, xai, minimax, mistral, gemini, +# elevenlabs, neutts, kittentts, piper) inherit sensible defaults from +# DEFAULT_CONFIG in hermes_cli/config.py. +# tts: +# provider: "deepinfra" +# deepinfra: +# # Model id is discovered live from the DeepInfra catalog filtered +# # by the `tts` surface tag — leave `model` blank to take the first +# # live result. +# model: "" +# voice: "default" + +# Image generation. Each provider plugin reads its own ``image_gen.<name>`` +# block; deepinfra discovers models live from +# api.deepinfra.com/v1/openai/models filtered by the ``image-gen`` tag — +# no model id is hardcoded, so retired models disappear automatically. +# image_gen: +# provider: "deepinfra" +# deepinfra: +# # Leave `model` blank for the first live `image-gen`-tagged result. +# model: "" # ============================================================================= # Response Pacing (Messaging Platforms) diff --git a/cli.py b/cli.py index 63b0240ec08..9887bb02978 100644 --- a/cli.py +++ b/cli.py @@ -543,6 +543,8 @@ def load_cli_config() -> Dict[str, Any]: if key == "model": continue # Already handled above if key in file_config: + if isinstance(defaults[key], dict) and file_config[key] is None: + continue if isinstance(defaults[key], dict) and isinstance(file_config[key], dict): defaults[key].update(file_config[key]) else: @@ -1110,6 +1112,19 @@ def _run_cleanup(*, notify_session_finalize: bool = True): ) try: if _active_agent_ref and hasattr(_active_agent_ref, 'shutdown_memory_provider'): + # A /new shortly before exit leaves its end→switch boundary task + # (old-session extraction, LLM-bound) queued on the memory + # manager's serialized worker. shutdown_all()'s drain only waits + # ~5s and cancels queued tasks, so give pending work a bounded + # head start via the manager's own barrier — otherwise a + # "/new then quit" silently drops the old session's extraction. + # The 30s exit watchdog remains the hard backstop. + _mm = getattr(_active_agent_ref, '_memory_manager', None) + if _mm is not None and hasattr(_mm, 'flush_pending'): + try: + _mm.flush_pending(timeout=10) + except Exception: + pass # Forward the agent's own transcript so memory providers' # ``on_session_end`` hooks see the real conversation instead of # an empty list (#15165). ``_session_messages`` is set on @@ -4847,6 +4862,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._pet_event = state self._pet_event_until = time.monotonic() + secs + def _on_reaction(self, kind: str) -> None: + """User affection (ily / <3 / good bot), core-detected — the pet's share + of the vibe signal that plays hearts on the TUI/desktop. Flash a celebrate.""" + if kind == "vibe": + self._pet_flash("jump") + def _pet_react_turn_end(self) -> None: """Flash the end-of-turn beat: failed on error, jump on a finished plan, else wave.""" if not self._pet_enabled: @@ -6956,17 +6977,69 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): ) return False + def _launch_session_boundary_memory_flush( + self, + history_snapshot: list, + *, + session_id: Optional[str] = None, + ) -> Optional[list]: + """Stage old-session memory extraction so /new stays responsive. + + The context-engine ``on_session_end`` boundary is delivered + synchronously here: it is cheap (local state clear, no LLM call) and + ordering-sensitive — it must land before ``reset_session_state()`` + rebinds the engine to the new session. + + The memory-provider half (LLM-bound extraction, seconds) is NOT run + here. The returned snapshot is handed by ``new_session()`` to + ``MemoryManager.commit_session_boundary_async`` as a single + end→switch task on the manager's serialized background worker, so + extraction can never race the provider rebinding (providers key off + internal ``_session_id`` state — a late ``on_session_end`` after + ``on_session_switch`` would misattribute the old transcript to the + new session). + + Returns the history snapshot to queue, or ``None`` when there is + nothing to extract (no agent / empty history / no memory manager). + """ + agent = getattr(self, "agent", None) + if not agent or not history_snapshot: + return None + + engine = getattr(agent, "context_compressor", None) + if engine is not None and hasattr(engine, "on_session_end"): + try: + engine.on_session_end(session_id or "", history_snapshot) + except Exception: + logger.debug( + "Context engine on_session_end failed at /new boundary", + exc_info=True, + ) + + # No provider extraction to queue when no memory manager is + # configured — new_session() falls back to the inline switch path. + if getattr(agent, "_memory_manager", None) is None: + return None + return history_snapshot + def new_session(self, silent=False, title=None): """Start a fresh session with a new session ID and cleared agent state.""" + old_session_id = self.session_id + _boundary_snapshot = None if self.agent and self.conversation_history: - # Trigger memory extraction on the old session before session_id rotates. - self.agent.commit_memory_session(self.conversation_history) + # Deliver the context-engine boundary synchronously and get back + # the history snapshot for the deferred provider extraction — + # queued below (after rotation) so /new never blocks on the + # LLM-bound extraction call. + _boundary_snapshot = self._launch_session_boundary_memory_flush( + list(self.conversation_history), + session_id=old_session_id, + ) self._notify_session_boundary("on_session_finalize") elif self.agent: # First session or empty history — still finalize the old session self._notify_session_boundary("on_session_finalize") - old_session_id = self.session_id if self._session_db and old_session_id: # Flush any un-persisted messages from the current turn to the # old session *before* rotating. /new can be called mid-turn @@ -7054,15 +7127,29 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # per-session state (_session_turns, _turn_counter, _document_id). # Fires BEFORE the plugin on_session_reset hook (shell hooks only # see the new id; Python providers see the transition). See #6672. + # + # When the old session has history, end-of-session extraction + # (LLM-bound, seconds) and this switch are queued as ONE task on + # the memory manager's serialized worker — end strictly before + # switch, without blocking /new (#16454). With no history there + # is nothing to extract; switch inline as before. try: _mm = getattr(self.agent, "_memory_manager", None) if _mm is not None: - _mm.on_session_switch( - self.session_id, - parent_session_id=old_session_id or "", - reset=True, - reason="new_session", - ) + if _boundary_snapshot: + _mm.commit_session_boundary_async( + _boundary_snapshot, + new_session_id=self.session_id, + parent_session_id=old_session_id or "", + reason="new_session", + ) + else: + _mm.on_session_switch( + self.session_id, + parent_session_id=old_session_id or "", + reset=True, + reason="new_session", + ) except Exception: pass self._notify_session_boundary("on_session_reset") @@ -7074,7 +7161,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): print("(^_^)v New session started!") - def _consume_pending_resume_selection(self, text: str) -> bool: """Resolve a bare numeric reply that follows a bare ``/resume`` prompt. @@ -11597,13 +11683,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): return "" def _approval_callback(self, command: str, description: str, - *, allow_permanent: bool = True) -> str: + *, allow_permanent: bool = True, + smart_denied: bool = False) -> str: """ Prompt for dangerous command approval through the prompt_toolkit UI. Called from the agent thread. Shows a selection UI similar to clarify - with choices: once / session / always / deny. When allow_permanent - is False (tirith warnings present), the 'always' option is hidden. + with choices: once / session / always / deny. Smart DENY owner + overrides show only once / deny. When allow_permanent is False for + another reason (for example tirith), only 'always' is hidden. Long commands also get a 'view' option so the full command can be expanded before deciding. @@ -11620,7 +11708,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._approval_state = { "command": command, "description": description, - "choices": self._approval_choices(command, allow_permanent=allow_permanent), + "choices": self._approval_choices( + command, + allow_permanent=allow_permanent, + smart_denied=smart_denied, + ), "selected": 0, "response_queue": response_queue, } @@ -11666,9 +11758,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): _cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}") return "deny" - def _approval_choices(self, command: str, *, allow_permanent: bool = True) -> list[str]: + def _approval_choices(self, command: str, *, allow_permanent: bool = True, + smart_denied: bool = False) -> list[str]: """Return approval choices for a dangerous command prompt.""" - choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"] + if smart_denied: + choices = ["once", "deny"] + else: + choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"] if len(command) > 70: choices.append("view") return choices @@ -15093,7 +15189,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): from tools.approval import get_current_session_key _drain_sk = get_current_session_key(default="") for _evt, _synth in process_registry.drain_notifications(session_key=_drain_sk): + from tools.async_delegation import ( + claim_event_delivery, complete_event_delivery, + ) + _claim = claim_event_delivery(_evt, "cli-idle") + if _claim is None: + continue self._pending_input.put(_synth) + complete_event_delivery(_evt, _claim) except Exception: pass continue @@ -15255,7 +15358,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): try: from tools.process_registry import process_registry for _evt, _synth in process_registry.drain_notifications(): + from tools.async_delegation import ( + claim_event_delivery, complete_event_delivery, + ) + _claim = claim_event_delivery(_evt, "cli-post-turn") + if _claim is None: + continue self._pending_input.put(_synth) + complete_event_delivery(_evt, _claim) except Exception: pass # Non-fatal — don't break the main loop diff --git a/cron/jobs.py b/cron/jobs.py index 76cfc60cbf5..90c318742e6 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -7,6 +7,8 @@ Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md import contextlib import copy +from contextvars import ContextVar +from dataclasses import dataclass import json import logging import shutil @@ -62,6 +64,9 @@ except ImportError: # the default root: that re-breaks per-profile isolation. See also the dynamic # `_get_hermes_home()` / `_get_lock_paths()` resolution in cron/scheduler.py. HERMES_DIR = get_hermes_home().resolve() +# These constants remain the default-profile fallback and a compatibility +# surface for existing callers/tests. Cross-profile callers must scope paths +# with use_cron_store() instead of mutating them process-wide. CRON_DIR = HERMES_DIR / "cron" JOBS_FILE = CRON_DIR / "jobs.json" # Heartbeat file the in-process ticker touches on every loop iteration. The @@ -96,6 +101,50 @@ _JOBS_LOCK_TIMEOUT_SECONDS = 30.0 OUTPUT_DIR = CRON_DIR / "output" ONESHOT_GRACE_SECONDS = 120 + +@dataclass(frozen=True) +class _CronStorePaths: + cron_dir: Path + jobs_file: Path + output_dir: Path + + +_cron_store_override: ContextVar[Optional[_CronStorePaths]] = ContextVar( + "cron_store_override", + default=None, +) + + +def _current_cron_store() -> _CronStorePaths: + """Return paths pinned to this execution context's profile.""" + override = _cron_store_override.get() + if override is not None: + return override + return _CronStorePaths(CRON_DIR, JOBS_FILE, OUTPUT_DIR) + + +@contextlib.contextmanager +def use_cron_store(home: Union[str, Path]): + """Route cron storage to ``home`` without mutating process globals.""" + cron_dir = Path(home).expanduser().resolve() / "cron" + token = _cron_store_override.set( + _CronStorePaths( + cron_dir=cron_dir, + jobs_file=cron_dir / "jobs.json", + output_dir=cron_dir / "output", + ) + ) + try: + yield + finally: + _cron_store_override.reset(token) + + +def get_cron_output_dir() -> Path: + """Return the output directory for the active cron store context.""" + return _current_cron_store().output_dir + + # Fallback stale-recovery window for a one-shot's running-claim (#59229) when # the cron inactivity timeout is disabled (HERMES_CRON_TIMEOUT=0 → unlimited), # in which case no finite run bound exists to derive from. Also acts as the @@ -143,9 +192,29 @@ def _oneshot_run_claim_ttl_seconds() -> float: ) +def _job_running_in_this_process(job_id: str) -> bool: + """Return True when the scheduler in THIS process is still running ``job_id``. + + Direct liveness signal for stale-entry recovery (#62002): the run_claim + TTL alone cannot distinguish "the claiming tick died" from "the run is + alive but slow" — a run stalled on network I/O (or a laptop that slept + mid-run) legitimately outlives the TTL. The in-process ticker and the run + share this process, so the scheduler's running set settles the common + single-gateway case without any claim-age guesswork. + + Imported lazily: the scheduler imports this module at load, so a + module-level import here would be circular. + """ + try: + from cron.scheduler import get_running_job_ids + return job_id in get_running_job_ids() + except Exception: + return False + + def _jobs_lock_file() -> Path: """Return the advisory lock path for the current cron directory.""" - return CRON_DIR / ".jobs.lock" + return _current_cron_store().cron_dir / ".jobs.lock" @contextlib.contextmanager @@ -265,7 +334,7 @@ def _job_output_dir(job_id: str) -> Path: raise ValueError(f"Invalid cron job id for output path: {job_id!r}") if Path(text).is_absolute() or Path(text).drive: raise ValueError(f"Invalid cron job id for output path: {job_id!r}") - return OUTPUT_DIR / text + return _current_cron_store().output_dir / text def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]: @@ -372,10 +441,11 @@ def _secure_file(path: Path): def ensure_dirs(): """Ensure cron directories exist with secure permissions.""" - CRON_DIR.mkdir(parents=True, exist_ok=True) - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - _secure_dir(CRON_DIR) - _secure_dir(OUTPUT_DIR) + store = _current_cron_store() + store.cron_dir.mkdir(parents=True, exist_ok=True) + store.output_dir.mkdir(parents=True, exist_ok=True) + _secure_dir(store.cron_dir) + _secure_dir(store.output_dir) # ============================================================================= @@ -559,7 +629,7 @@ def _recoverable_oneshot_run_at( their requested minute still run on the next tick. Once a one-shot has already run, it is never eligible again. """ - if schedule.get("kind") != "once": + if not isinstance(schedule, dict) or schedule.get("kind") != "once": return None if last_run_at: return None @@ -568,7 +638,10 @@ def _recoverable_oneshot_run_at( if not run_at: return None - run_at_dt = _ensure_aware(datetime.fromisoformat(run_at)) + try: + run_at_dt = _ensure_aware(datetime.fromisoformat(run_at)) + except Exception: + return None if run_at_dt >= now - timedelta(seconds=ONESHOT_GRACE_SECONDS): return run_at return None @@ -592,16 +665,18 @@ def _compute_grace_seconds(schedule: dict) -> int: return max(MIN_GRACE, min(grace, MAX_GRACE)) if kind == "cron" and HAS_CRONITER: - try: - now = _hermes_now() - cron = croniter(schedule["expr"], now) - first = cron.get_next(datetime) - second = cron.get_next(datetime) - period_seconds = int((second - first).total_seconds()) - grace = period_seconds // 2 - return max(MIN_GRACE, min(grace, MAX_GRACE)) - except Exception: - pass + expr = schedule.get("expr") + if expr: + try: + now = _hermes_now() + cron = croniter(expr, now) + first = cron.get_next(datetime) + second = cron.get_next(datetime) + period_seconds = int((second - first).total_seconds()) + grace = period_seconds // 2 + return max(MIN_GRACE, min(grace, MAX_GRACE)) + except Exception: + pass return MIN_GRACE @@ -614,28 +689,41 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None """ now = _hermes_now() - if schedule["kind"] == "once": + if not isinstance(schedule, dict): + return None + kind = schedule.get("kind") + if kind is None: + return None + + if kind == "once": return _recoverable_oneshot_run_at(schedule, now, last_run_at=last_run_at) - elif schedule["kind"] == "interval": - minutes = schedule["minutes"] + elif kind == "interval": + minutes = schedule.get("minutes") + if minutes is None: + return None if last_run_at: - # Next run is last_run + interval - last = _ensure_aware(datetime.fromisoformat(last_run_at)) - next_run = last + timedelta(minutes=minutes) + try: + last = _ensure_aware(datetime.fromisoformat(last_run_at)) + next_run = last + timedelta(minutes=minutes) + except Exception: + next_run = now + timedelta(minutes=minutes) else: # First run is now + interval next_run = now + timedelta(minutes=minutes) return next_run.isoformat() - elif schedule["kind"] == "cron": + elif kind == "cron": + expr = schedule.get("expr") + if not expr: + return None if not HAS_CRONITER: logger.warning( "Cannot compute next run for cron schedule %r: 'croniter' is " "not installed. croniter is a core dependency as of v0.9.x; " "reinstall hermes-agent or run 'pip install croniter' in your " "runtime env.", - schedule.get("expr"), + expr, ) return None # Use last_run_at as the croniter base when available, consistent @@ -644,8 +732,11 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None # rather than to an arbitrary restart time. base_time = now if last_run_at: - base_time = _ensure_aware(datetime.fromisoformat(last_run_at)) - cron = croniter(schedule["expr"], base_time) + try: + base_time = _ensure_aware(datetime.fromisoformat(last_run_at)) + except Exception: + base_time = now + cron = croniter(expr, base_time) next_run = cron.get_next(datetime) return next_run.isoformat() @@ -664,7 +755,7 @@ def _atomic_write_epoch(path: Path) -> None: torn/truncated file. Best-effort: failures are swallowed by callers. """ ensure_dirs() - fd, tmp_path = tempfile.mkstemp(dir=str(CRON_DIR), suffix=".tmp", prefix=".hb_") + fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp", prefix=".hb_") try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(str(time.time())) @@ -730,20 +821,21 @@ def get_ticker_success_age() -> Optional[float]: def load_jobs() -> List[Dict[str, Any]]: """Load all jobs from storage.""" + jobs_file = _current_cron_store().jobs_file ensure_dirs() - if not JOBS_FILE.exists(): + if not jobs_file.exists(): return [] _strict_retry = False # track whether we used the strict=False fallback try: - with open(JOBS_FILE, 'r', encoding='utf-8') as f: + with open(jobs_file, 'r', encoding='utf-8') as f: data = json.load(f) except json.JSONDecodeError: # Retry with strict=False to handle bare control chars in string values _strict_retry = True try: - with open(JOBS_FILE, 'r', encoding='utf-8') as f: + with open(jobs_file, 'r', encoding='utf-8') as f: data = json.loads(f.read(), strict=False) except Exception as e: logger.error("Failed to auto-repair jobs.json: %s", e) @@ -778,15 +870,16 @@ def load_jobs() -> List[Dict[str, Any]]: def _save_jobs_unlocked(jobs: List[Dict[str, Any]]): """Save all jobs to storage. Caller must hold _jobs_lock().""" + jobs_file = _current_cron_store().jobs_file ensure_dirs() - fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_') + fd, tmp_path = tempfile.mkstemp(dir=str(jobs_file.parent), suffix='.tmp', prefix='.jobs_') try: with os.fdopen(fd, 'w', encoding='utf-8') as f: json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f, indent=2) f.flush() os.fsync(f.fileno()) - atomic_replace(tmp_path, JOBS_FILE) - _secure_file(JOBS_FILE) + atomic_replace(tmp_path, jobs_file) + _secure_file(jobs_file) except BaseException: try: os.unlink(tmp_path) @@ -1452,7 +1545,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None, "Job '%s' (%s) could not compute next_run_at; " "leaving enabled and marking state=error so the " "job is not silently disabled.", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), kind, ) else: @@ -1506,7 +1599,7 @@ def claim_dispatch(job_id: str) -> bool: save_jobs(jobs) logger.info( "Job '%s': dispatch limit reached (%d/%d) — removing", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), completed, times, ) @@ -1516,7 +1609,7 @@ def claim_dispatch(job_id: str) -> bool: save_jobs(jobs) logger.debug( "Job '%s': claimed dispatch %d/%d", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), repeat["completed"], times, ) @@ -1530,6 +1623,38 @@ def claim_dispatch(job_id: str) -> bool: return True +def heartbeat_run_claim(job_id: str, *, expected_owner: str) -> bool: + """Refresh a one-shot's ``run_claim`` timestamp while its run is alive. + + Called periodically from the scheduler's run monitor (#62002) so a + legitimately long run keeps its claim fresh: an expired claim then really + does mean "the claiming process died", and neither another process's tick + nor this process's own next tick will re-dispatch or stale-remove the job + while the run is in flight. mark_job_run() clears the claim on completion. + + ``expected_owner`` is the stable owner copied from the dispatched job. The + compare-and-refresh prevents a stale runner that resumes after a long sleep + from extending a claim another scheduler process has since taken over. + + Returns True if this owner's one-shot claim was refreshed; False when the + job, claim, or ownership no longer matches. + """ + with _jobs_lock(): + jobs = load_jobs() + for job in jobs: + if job.get("id") != job_id: + continue + if job.get("schedule", {}).get("kind") != "once": + return False + claim = job.get("run_claim") + if not isinstance(claim, dict) or claim.get("by") != expected_owner: + return False + claim["at"] = _hermes_now().isoformat() + save_jobs(jobs) + return True + return False + + def advance_next_run(job_id: str) -> bool: """Preemptively advance next_run_at for a recurring job before execution. @@ -1653,209 +1778,331 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: """Inner implementation of get_due_jobs(); must be called with _jobs_lock held.""" now = _hermes_now() raw_jobs = load_jobs() + needs_save = False + + # Repair id-less records BEFORE anything keys off ``job["id"]``. A direct + # jobs.json edit that bypassed add_job() can leave a record without an "id" + # (older writers used "job_id"). Every downstream site — the logging + # helpers and the ``for rj in raw_jobs: if rj["id"] == job["id"]`` + # persistence loops — indexes job["id"] eagerly, so a single malformed + # record raised KeyError mid-tick, aborting the whole scan before + # save_jobs() ran. That froze the entire profile's scheduler in a + # per-minute fast-forward loop (healthy jobs recomputed in memory, then + # discarded when the exception unwound). Recover the id from the drifted + # "job_id" key when present, else synthesize one, and persist. + for rj in raw_jobs: + if not rj.get("id"): + rj["id"] = rj.pop("job_id", None) or uuid.uuid4().hex[:12] + needs_save = True + jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)] due = [] - needs_save = False + + # Normalize malformed "schedule" records (direct jobs.json edit, old writers, + # corruption, etc.). "schedule" must be a dict; a null/string/etc. value + # makes `schedule.get("kind")` or direct `schedule["kind"]` / ["expr"] / + # ["minutes"] later raise and abort the entire scan *before* save_jobs(). + # Healthy jobs then lose their fast-forwarded next_run_at (exactly the + # failure mode of the id-less job bug fixed above). Repair early at the + # source so the rest of the tick can proceed and persist progress for + # siblings. + for j in jobs: + if not isinstance(j.get("schedule"), dict): + j["schedule"] = {} + needs_save = True + for rj in raw_jobs: + if not isinstance(rj.get("schedule"), dict): + rj["schedule"] = {} + needs_save = True + + # Normalize malformed "next_run_at" records (direct jobs.json edit, + # corruption, migration, or buggy writer). If present but not a valid + # ISO string, datetime.fromisoformat(next_run) later raises and aborts + # the entire scan *before* save_jobs(). Healthy siblings then lose any + # fast-forwarded next_run_at (same class of bug as bad "id" or "schedule"). + # Strip the bad value so the existing "no next_run_at" recovery path + # recomputes a sane value and persists it for this job. + for j in jobs: + nr = j.get("next_run_at") + if nr is not None: + if not isinstance(nr, str): + j.pop("next_run_at", None) + needs_save = True + else: + try: + datetime.fromisoformat(nr) + except Exception: + j.pop("next_run_at", None) + needs_save = True + for rj in raw_jobs: + nr = rj.get("next_run_at") + if nr is not None: + if not isinstance(nr, str): + rj.pop("next_run_at", None) + needs_save = True + else: + try: + datetime.fromisoformat(nr) + except Exception: + rj.pop("next_run_at", None) + needs_save = True + + # Same treatment for last_run_at (used as base in recovery / compute_next_run). + for j in jobs: + lr = j.get("last_run_at") + if lr is not None and not isinstance(lr, str): + j.pop("last_run_at", None) + needs_save = True + elif isinstance(lr, str): + try: + datetime.fromisoformat(lr) + except Exception: + j.pop("last_run_at", None) + needs_save = True + for rj in raw_jobs: + lr = rj.get("last_run_at") + if lr is not None and not isinstance(lr, str): + rj.pop("last_run_at", None) + needs_save = True + elif isinstance(lr, str): + try: + datetime.fromisoformat(lr) + except Exception: + rj.pop("last_run_at", None) + needs_save = True + # Resolve the one-shot running-claim stale-recovery TTL once per scan # (derived from HERMES_CRON_TIMEOUT). See _oneshot_run_claim_ttl_seconds. _run_claim_ttl = _oneshot_run_claim_ttl_seconds() for job in jobs: - if not job.get("enabled", True): - continue - - # Cross-process running-claim guard (#59229): if another scheduler - # process already claimed this one-shot and its run is still in flight - # (claim younger than the TTL), skip it — do NOT re-dispatch. The - # claim is stamped just before we return the job as due (below) and - # cleared by mark_job_run() on completion. A claim older than the TTL - # is treated as stale (the claiming tick died mid-run) and allowed - # through so the job is recovered rather than wedged forever. - existing_claim = job.get("run_claim") - if existing_claim and job.get("schedule", {}).get("kind") == "once": - try: - claimed_at = _ensure_aware( - datetime.fromisoformat(existing_claim["at"]) - ) - # 0 <= age: a future-dated claim (clock/TZ skew across a - # restart) must be treated as stale, not eternally fresh, - # or the one-shot is skipped forever (#60703). - _age = (now - claimed_at).total_seconds() - if 0 <= _age < _run_claim_ttl: - continue # a fresh claim is held by an in-flight run - except (KeyError, ValueError, TypeError): - pass # malformed claim → fall through and (re)claim - - next_run = job.get("next_run_at") - if not next_run: - schedule = job.get("schedule", {}) - kind = schedule.get("kind") - - # One-shot jobs use a small grace window via the dedicated helper. - recovered_next = _recoverable_oneshot_run_at( - schedule, - now, - last_run_at=job.get("last_run_at"), - ) - recovery_kind = "one-shot" if recovered_next else None - - # Recurring jobs reach here only when something — typically a - # direct jobs.json edit that bypassed add_job() — left - # next_run_at unset. Without this branch, such jobs are - # silently skipped forever; recompute next_run_at from the - # schedule so they pick up at their next scheduled tick. - if not recovered_next and kind in {"cron", "interval"}: - recovered_next = compute_next_run(schedule, now.isoformat()) - if recovered_next: - recovery_kind = kind - - if not recovered_next: + # Per-job containment (structural guard): one malformed or + # unexpected job record must never abort the whole scan. The id / + # schedule / timestamp normalizations above repair the known shapes; + # this guard catches every FUTURE variant, degrading to "skip this + # job this tick" so healthy siblings still run and their recovered + # state still reaches save_jobs() below. + try: + if not job.get("enabled", True): continue - job["next_run_at"] = recovered_next - next_run = recovered_next - logger.info( - "Job '%s' had no next_run_at; recovering %s run at %s", - job.get("name", job["id"]), - recovery_kind, - recovered_next, - ) - for rj in raw_jobs: - if rj["id"] == job["id"]: - rj["next_run_at"] = recovered_next - needs_save = True - break + # Cross-process running-claim guard (#59229): if another scheduler + # process already claimed this one-shot and its run is still in flight + # (claim younger than the TTL), skip it — do NOT re-dispatch. The + # claim is stamped just before we return the job as due (below) and + # cleared by mark_job_run() on completion. A claim older than the TTL + # is treated as stale (the claiming tick died mid-run) and allowed + # through so the job is recovered rather than wedged forever. + existing_claim = job.get("run_claim") + if existing_claim and job.get("schedule", {}).get("kind") == "once": + try: + claimed_at = _ensure_aware( + datetime.fromisoformat(existing_claim["at"]) + ) + # 0 <= age: a future-dated claim (clock/TZ skew across a + # restart) must be treated as stale, not eternally fresh, + # or the one-shot is skipped forever (#60703). + _age = (now - claimed_at).total_seconds() + if 0 <= _age < _run_claim_ttl: + continue # a fresh claim is held by an in-flight run + except (KeyError, ValueError, TypeError): + pass # malformed claim → fall through and (re)claim - raw_next_run_dt = datetime.fromisoformat(next_run) - schedule = job.get("schedule", {}) - kind = schedule.get("kind") + next_run = job.get("next_run_at") + if not next_run: + schedule = job.get("schedule", {}) + kind = schedule.get("kind") - next_run_dt = _ensure_aware(raw_next_run_dt) - # Migration repair: a cron job persists next_run_at as an absolute - # instant, but the cron expr describes local wall-clock intent. If the - # configured/system timezone changed after persistence, the stored - # instant's offset no longer matches now's, and its converted time can - # look due hours early (21:00+10 -> 13:00+02). When the stored *wall - # clock* is still in the future, recompute from the schedule so we fire - # at the intended local time instead of early-then-again. - # - # TRADE-OFF: this cannot distinguish a config/host TZ migration from a - # legitimate DST offset change. A DST boundary that satisfies all four - # conditions will recompute (and thus SKIP the pending occurrence, no - # catch-up) rather than fire it. Accepted: in the pure-migration case - # the recompute lands on the same wall-clock time later the same period, - # and DST-boundary collisions with a still-future stored wall clock are - # rare relative to the double-fire bug this prevents (#28934). - if ( - kind == "cron" - and next_run_dt <= now - and _timezone_offset_mismatch(raw_next_run_dt, now) - and _stored_wall_clock_is_future(raw_next_run_dt, now) - ): - new_next = compute_next_run(schedule, now.isoformat()) - if new_next: + # One-shot jobs use a small grace window via the dedicated helper. + recovered_next = _recoverable_oneshot_run_at( + schedule, + now, + last_run_at=job.get("last_run_at"), + ) + recovery_kind = "one-shot" if recovered_next else None + + # Recurring jobs reach here only when something — typically a + # direct jobs.json edit that bypassed add_job() — left + # next_run_at unset. Without this branch, such jobs are + # silently skipped forever; recompute next_run_at from the + # schedule so they pick up at their next scheduled tick. + if not recovered_next and kind in {"cron", "interval"}: + recovered_next = compute_next_run(schedule, now.isoformat()) + if recovered_next: + recovery_kind = kind + + if not recovered_next: + continue + + job["next_run_at"] = recovered_next + next_run = recovered_next logger.info( - "Job '%s' next_run_at offset changed (%s -> %s). " - "Recomputing cron run to preserve local wall-clock intent: %s", - job.get("name", job["id"]), - raw_next_run_dt.utcoffset(), - now.utcoffset(), - new_next, + "Job '%s' had no next_run_at; recovering %s run at %s", + job.get("name", job.get("id", "?")), + recovery_kind, + recovered_next, ) for rj in raw_jobs: if rj["id"] == job["id"]: - rj["next_run_at"] = new_next + rj["next_run_at"] = recovered_next needs_save = True break - continue - if next_run_dt <= now: + raw_next_run_dt = datetime.fromisoformat(next_run) + schedule = job.get("schedule", {}) + kind = schedule.get("kind") - # For recurring jobs, check if the scheduled time is stale - # (gateway was down and missed the window). Fast-forward to - # the next future occurrence instead of firing a stale run. - grace = _compute_grace_seconds(schedule) - if kind in {"cron", "interval"} and (now - next_run_dt).total_seconds() > grace: - # Job is past its catch-up grace window — skip accumulated - # missed runs but still execute once now to avoid deferring - # indefinitely (e.g. a long-running job just finished). + next_run_dt = _ensure_aware(raw_next_run_dt) + # Migration repair: a cron job persists next_run_at as an absolute + # instant, but the cron expr describes local wall-clock intent. If the + # configured/system timezone changed after persistence, the stored + # instant's offset no longer matches now's, and its converted time can + # look due hours early (21:00+10 -> 13:00+02). When the stored *wall + # clock* is still in the future, recompute from the schedule so we fire + # at the intended local time instead of early-then-again. + # + # TRADE-OFF: this cannot distinguish a config/host TZ migration from a + # legitimate DST offset change. A DST boundary that satisfies all four + # conditions will recompute (and thus SKIP the pending occurrence, no + # catch-up) rather than fire it. Accepted: in the pure-migration case + # the recompute lands on the same wall-clock time later the same period, + # and DST-boundary collisions with a still-future stored wall clock are + # rare relative to the double-fire bug this prevents (#28934). + if ( + kind == "cron" + and next_run_dt <= now + and _timezone_offset_mismatch(raw_next_run_dt, now) + and _stored_wall_clock_is_future(raw_next_run_dt, now) + ): new_next = compute_next_run(schedule, now.isoformat()) if new_next: logger.info( - "Job '%s' missed its scheduled time (%s, grace=%ds). " - "Running now; next run provisionally set to: %s " - "(re-anchored on completion)", - job.get("name", job["id"]), - next_run, - grace, + "Job '%s' next_run_at offset changed (%s -> %s). " + "Recomputing cron run to preserve local wall-clock intent: %s", + job.get("name", job.get("id", "?")), + raw_next_run_dt.utcoffset(), + now.utcoffset(), new_next, ) - # Persist the fast-forward to storage now (skip accumulated - # slots). In the built-in ticker path this is shortly - # overwritten by advance_next_run + mark_job_run, but it is - # NOT redundant: it (a) protects the crash window between - # here and mark_job_run, and (b) covers the external - # fire_due provider path, which does not call - # advance_next_run. mark_job_run re-anchors next_run_at off - # the actual completion time, so this value is provisional. for rj in raw_jobs: if rj["id"] == job["id"]: rj["next_run_at"] = new_next needs_save = True break - # Fall through to due.append(job) — execute once now + continue - # One-shot dispatch-limit guard (issue #38758): a finite one-shot - # claimed via claim_dispatch() but whose tick died before - # mark_job_run could remove it will have completed >= times while - # still looking due (last_run_at was never written, so the - # recovery helper re-armed it). Remove it instead of re-firing. - if kind == "once": - repeat = job.get("repeat") - if repeat: - times = repeat.get("times") - completed = repeat.get("completed", 0) - if times is not None and times > 0 and completed >= times: + if next_run_dt <= now: + + # For recurring jobs, check if the scheduled time is stale + # (gateway was down and missed the window). Fast-forward to + # the next future occurrence instead of firing a stale run. + grace = _compute_grace_seconds(schedule) + if kind in {"cron", "interval"} and (now - next_run_dt).total_seconds() > grace: + # Job is past its catch-up grace window — skip accumulated + # missed runs but still execute once now to avoid deferring + # indefinitely (e.g. a long-running job just finished). + new_next = compute_next_run(schedule, now.isoformat()) + if new_next: logger.info( - "Job '%s': one-shot dispatch limit reached (%d/%d) " - "— removing stale due entry", - job.get("name", job["id"]), - completed, - times, + "Job '%s' missed its scheduled time (%s, grace=%ds). " + "Running now; next run provisionally set to: %s " + "(re-anchored on completion)", + job.get("name", job.get("id", "?")), + next_run, + grace, + new_next, ) + # Persist the fast-forward to storage now (skip accumulated + # slots). In the built-in ticker path this is shortly + # overwritten by advance_next_run + mark_job_run, but it is + # NOT redundant: it (a) protects the crash window between + # here and mark_job_run, and (b) covers the external + # fire_due provider path, which does not call + # advance_next_run. mark_job_run re-anchors next_run_at off + # the actual completion time, so this value is provisional. for rj in raw_jobs: if rj["id"] == job["id"]: - raw_jobs.remove(rj) + rj["next_run_at"] = new_next needs_save = True break - continue + # Fall through to due.append(job) — execute once now - # Durably claim a one-shot for the DURATION of its run before - # returning it as due, so a second scheduler process (gateway + - # desktop both run in-process 60s tickers on one HERMES_HOME) - # cannot re-dispatch it while the first run is still in flight - # (#59229). A plain one-shot's due-state is not resolved until - # mark_job_run() completes it minutes later, so advancing - # next_run_at by a fixed window is not enough — a job that outlives - # one tick (e.g. a 2.5-min research prompt) would simply re-fire on - # the next tick after the window. Instead we stamp a run_claim under - # the same lock get_due_jobs already holds; the other process reads - # a fresh claim on its next tick and skips (handled at the top of - # this loop). mark_job_run() clears the claim on completion. The TTL - # is only a safety valve: a claiming tick that DIES mid-run leaves a - # stale claim that expires after the resolved run-claim TTL - # (_oneshot_run_claim_ttl_seconds, derived from HERMES_CRON_TIMEOUT), - # so the job is re-dispatched rather than wedged forever. - if kind == "once": - claim = {"at": now.isoformat(), "by": _machine_id()} - job["run_claim"] = claim - for rj in raw_jobs: - if rj["id"] == job["id"]: - rj["run_claim"] = claim - needs_save = True - break + # One-shot dispatch-limit guard (issue #38758): a finite one-shot + # claimed via claim_dispatch() but whose tick died before + # mark_job_run could remove it will have completed >= times while + # still looking due (last_run_at was never written, so the + # recovery helper re-armed it). Remove it instead of re-firing. + if kind == "once": + repeat = job.get("repeat") + if repeat: + times = repeat.get("times") + completed = repeat.get("completed", 0) + if times is not None and times > 0 and completed >= times: + # A live run must never have its job record deleted + # underneath it (#62002): a run that outlives the + # run_claim TTL (stream stall, laptop asleep + # mid-run) satisfies the same completed >= times + + # expired-claim condition as a dead tick, but + # mark_job_run() still needs the record to land + # last_run_at / last_status / last_delivery_error. + # If this process is still running the job, it is + # slow, not stale — keep the entry and skip. + if _job_running_in_this_process(job.get("id", "")): + logger.info( + "Job '%s': dispatch limit reached (%d/%d) " + "but its run is still in flight in this " + "process — keeping entry", + job.get("name", job.get("id", "?")), + completed, + times, + ) + continue + logger.info( + "Job '%s': one-shot dispatch limit reached (%d/%d) " + "— removing stale due entry", + job.get("name", job.get("id", "?")), + completed, + times, + ) + for rj in raw_jobs: + if rj["id"] == job["id"]: + raw_jobs.remove(rj) + needs_save = True + break + continue - due.append(job) + # Durably claim a one-shot for the DURATION of its run before + # returning it as due, so a second scheduler process (gateway + + # desktop both run in-process 60s tickers on one HERMES_HOME) + # cannot re-dispatch it while the first run is still in flight + # (#59229). A plain one-shot's due-state is not resolved until + # mark_job_run() completes it minutes later, so advancing + # next_run_at by a fixed window is not enough — a job that outlives + # one tick (e.g. a 2.5-min research prompt) would simply re-fire on + # the next tick after the window. Instead we stamp a run_claim under + # the same lock get_due_jobs already holds; the other process reads + # a fresh claim on its next tick and skips (handled at the top of + # this loop). mark_job_run() clears the claim on completion. The TTL + # is only a safety valve: a claiming tick that DIES mid-run leaves a + # stale claim that expires after the resolved run-claim TTL + # (_oneshot_run_claim_ttl_seconds, derived from HERMES_CRON_TIMEOUT), + # so the job is re-dispatched rather than wedged forever. + if kind == "once": + claim = {"at": now.isoformat(), "by": _machine_id()} + job["run_claim"] = claim + for rj in raw_jobs: + if rj["id"] == job["id"]: + rj["run_claim"] = claim + needs_save = True + break + + due.append(job) + except Exception: + logger.exception( + "Skipping malformed cron job %r during due scan", + job.get("name") or job.get("id") or "?", + ) + continue if needs_save: save_jobs(raw_jobs) diff --git a/cron/scheduler.py b/cron/scheduler.py index 9e645d3728f..9d8dc750775 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -20,6 +20,7 @@ import shutil import subprocess import sys import threading +import time # fcntl is Unix-only; on Windows use msvcrt for file locking try: @@ -237,7 +238,7 @@ _LEGACY_HOME_TARGET_ENV_VARS = { "QQBOT_HOME_CHANNEL": "QQ_HOME_CHANNEL", } -from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run, claim_dispatch +from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run, claim_dispatch, heartbeat_run_claim # Sentinel: when a cron agent has nothing new to report, it can start its # response with this marker to suppress delivery. Output is still saved @@ -2213,7 +2214,8 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: # Inject output from referenced cron jobs as context. context_from = job.get("context_from") if context_from: - from cron.jobs import OUTPUT_DIR + from cron.jobs import get_cron_output_dir + output_dir = get_cron_output_dir() if isinstance(context_from, str): context_from = [context_from] for source_job_id in context_from: @@ -2228,7 +2230,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: ) continue try: - job_output_dir = OUTPUT_DIR / source_job_id + job_output_dir = output_dir / source_job_id if not job_output_dir.exists(): continue # silent skip — no output yet output_files = sorted( @@ -2615,10 +2617,68 @@ def run_job( # Initialize SQLite session store so cron job messages are persisted # and discoverable via session_search (same pattern as gateway/run.py). + # + # Bounded with its own timeout (separate from HERMES_CRON_TIMEOUT, which + # only watches the agent's run_conversation below): SessionDB.__init__ + # opens/migrates state.db synchronously and has no timeout of its own + # against a wedged sqlite3.connect (e.g. a stale flock left by a crashed + # sibling process). An unbounded hang here is invisible to every other + # cron safeguard, because it happens BEFORE _submit_with_guard's future + # exists — the finally block that releases the job from + # _running_job_ids never runs, so the job stays wedged "running" until + # the whole gateway process is restarted, silently skipping every + # scheduled fire in between with "already running — skipping". _session_db = None try: from hermes_state import SessionDB - _session_db = SessionDB() + + # Resolve timeout: env override → config.yaml → default 10s. + # Mirrors the script_timeout_seconds resolution pattern. + _session_db_timeout: float | None = None + _raw_env_timeout = os.getenv("HERMES_CRON_SESSION_DB_TIMEOUT", "").strip() + if _raw_env_timeout: + try: + _session_db_timeout = float(_raw_env_timeout) + except (ValueError, TypeError): + logger.warning( + "Invalid HERMES_CRON_SESSION_DB_TIMEOUT=%r; using config/default", + _raw_env_timeout, + ) + if _session_db_timeout is None: + try: + from hermes_cli.config import load_config + _cfg = load_config() or {} + _cron_cfg = _cfg.get("cron", {}) if isinstance(_cfg, dict) else {} + _configured = _cron_cfg.get("session_db_timeout_seconds") + if _configured is not None: + _session_db_timeout = float(_configured) + except Exception as exc: + logger.debug( + "Failed to load cron.session_db_timeout_seconds from config: %s", + exc, + ) + if _session_db_timeout is None: + _session_db_timeout = 10.0 + + if _session_db_timeout > 0: + _session_db_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) + try: + _session_db = _session_db_pool.submit(SessionDB).result(timeout=_session_db_timeout) + finally: + # Don't wait for a wedged connect() to unwind — abandon the + # worker thread (same pattern as the agent inactivity timeout + # further down) rather than blocking shutdown on it too. + _session_db_pool.shutdown(wait=False) + else: + # 0 = unlimited (legacy behavior, opt-in for debugging) + _session_db = SessionDB() + except concurrent.futures.TimeoutError: + logger.error( + "Job '%s': SessionDB init did not return within %.0fs — proceeding " + "without a session store for this run instead of blocking it " + "forever", + job.get("id", "?"), _session_db_timeout, + ) except Exception as e: logger.debug("Job '%s': SQLite session store not available: %s", job.get("id", "?"), e) @@ -3096,6 +3156,39 @@ def run_job( _cron_timeout = 600.0 _cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None _POLL_INTERVAL = 5.0 + # Keep the one-shot run_claim fresh while the run is alive (#62002): + # the claim TTL is a dead-owner detector, but without a heartbeat a + # run that legitimately outlives it (stream stall, laptop asleep + # mid-run) is indistinguishable from a dead tick — another process + # re-dispatches it and get_due_jobs stale-removes the job record out + # from under the live run. Refreshing the claim from this monitor + # keeps "expired claim" meaning "owner died". + _job_schedule = job.get("schedule") + _is_oneshot = ( + isinstance(_job_schedule, dict) and _job_schedule.get("kind") == "once" + ) + _run_claim = job.get("run_claim") + _run_claim_owner = ( + str(_run_claim.get("by") or "") if isinstance(_run_claim, dict) else "" + ) + _CLAIM_HEARTBEAT_SECONDS = 60.0 + _last_claim_heartbeat = time.monotonic() + + def _heartbeat_run_claim_if_due(): + nonlocal _last_claim_heartbeat + if not _is_oneshot or not _run_claim_owner: + return + _mono = time.monotonic() + if _mono - _last_claim_heartbeat < _CLAIM_HEARTBEAT_SECONDS: + return + _last_claim_heartbeat = _mono + try: + heartbeat_run_claim(job_id, expected_owner=_run_claim_owner) + except Exception: + logger.debug( + "Job '%s': run_claim heartbeat failed", job_name, exc_info=True + ) + _cron_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) # Preserve scheduler-scoped ContextVar state (for example skill-declared # env passthrough registrations) when the cron run hops into the worker @@ -3105,8 +3198,20 @@ def run_job( _inactivity_timeout = False try: if _cron_inactivity_limit is None: - # Unlimited — just wait for the result. - result = _cron_future.result() + # Unlimited — no inactivity watchdog, but a one-shot still + # needs its run_claim heartbeat, so poll instead of blocking. + if _is_oneshot: + result = None + while True: + done, _ = concurrent.futures.wait( + {_cron_future}, timeout=_POLL_INTERVAL, + ) + if done: + result = _cron_future.result() + break + _heartbeat_run_claim_if_due() + else: + result = _cron_future.result() else: result = None while True: @@ -3116,6 +3221,7 @@ def run_job( if done: result = _cron_future.result() break + _heartbeat_run_claim_if_due() # Agent still running — check inactivity. _idle_secs = 0.0 if hasattr(agent, "get_activity_summary"): @@ -3509,7 +3615,14 @@ def _notify_provider_jobs_changed() -> None: logger.debug("on_jobs_changed notify failed: %s", e) -def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> int: +def tick( + verbose: bool = True, + adapters=None, + loop=None, + sync: bool = True, + *, + can_dispatch=None, +): """ Check and run all due jobs. @@ -3520,7 +3633,9 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i verbose: Whether to print status messages adapters: Optional dict mapping Platform → live adapter (from gateway) loop: Optional asyncio event loop (from gateway) for live adapter sends - + can_dispatch: Optional synchronous gate; false leaves due jobs untouched + for the next allowed tick + Returns: Number of jobs executed (0 if another tick is already running) """ @@ -3542,6 +3657,10 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i return 0 try: + if can_dispatch is not None and not can_dispatch(): + logger.debug("Cron dispatch paused while gateway drains existing work") + return 0 + due_jobs = get_due_jobs() if verbose and not due_jobs: diff --git a/cron/scheduler_provider.py b/cron/scheduler_provider.py index ab3121bfa3b..5429f79cf36 100644 --- a/cron/scheduler_provider.py +++ b/cron/scheduler_provider.py @@ -156,14 +156,16 @@ class InProcessCronScheduler(CronScheduler): ``start()`` blocks in the tick loop until ``stop_event`` is set, identical to the pre-refactor ``_start_cron_ticker`` core loop. The caller runs it in - a daemon thread. + a daemon thread. ``can_dispatch`` is an optional synchronous gate supplied + by GatewayRunner during external drain; skipped ticks leave due jobs intact + for the next allowed tick. """ @property def name(self) -> str: return "builtin" - def start(self, stop_event, *, adapters=None, loop=None, interval=60): + def start(self, stop_event, *, adapters=None, loop=None, interval=60, can_dispatch=None): import logging from cron.scheduler import tick as cron_tick from cron.jobs import record_ticker_heartbeat @@ -176,7 +178,16 @@ class InProcessCronScheduler(CronScheduler): while not stop_event.is_set(): ok = False try: - cron_tick(verbose=False, adapters=adapters, loop=loop, sync=False) + if can_dispatch is not None and not can_dispatch(): + logger.debug("Cron dispatch paused while gateway drains existing work") + else: + cron_tick( + verbose=False, + adapters=adapters, + loop=loop, + sync=False, + can_dispatch=can_dispatch, + ) ok = True except BaseException as e: # Catch BaseException (not just Exception) so a SystemExit from diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 0a9a2efddf8..ff207d86cb3 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -6,6 +6,7 @@ Built on gateway startup, refreshed periodically (every 5 min), and saved to action="list" and for resolving human-friendly channel names to numeric IDs. """ +import asyncio import json import logging from datetime import datetime @@ -121,7 +122,7 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: for platform, adapter in adapters.items(): try: if platform == Platform.DISCORD: - platforms["discord"] = _build_discord(adapter) + platforms["discord"] = await asyncio.to_thread(_build_discord, adapter) elif platform == Platform.SLACK: platforms["slack"] = await _build_slack(adapter) except Exception as e: @@ -142,7 +143,7 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: or plat_name not in adapter_platform_names ): continue - platforms[plat_name] = _build_from_sessions(plat_name) + platforms[plat_name] = await asyncio.to_thread(_build_from_sessions, plat_name) # Include plugin-registered platforms (dynamic enum members aren't in # Platform.__members__, so the loop above misses them). Same @@ -156,7 +157,7 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: and entry.name not in platforms and entry.name in adapter_platform_names ): - platforms[entry.name] = _build_from_sessions(entry.name) + platforms[entry.name] = await asyncio.to_thread(_build_from_sessions, entry.name) except Exception: pass @@ -223,7 +224,7 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]: """ team_clients = getattr(adapter, "_team_clients", None) or {} if not team_clients: - return _build_from_sessions("slack") + return await asyncio.to_thread(_build_from_sessions, "slack") channels: List[Dict[str, Any]] = [] seen_ids: set = set() @@ -267,7 +268,7 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]: continue # Merge in DM/group entries discovered from session history. - for entry in _build_from_sessions("slack"): + for entry in await asyncio.to_thread(_build_from_sessions, "slack"): if entry.get("id") not in seen_ids: channels.append(entry) seen_ids.add(entry.get("id")) diff --git a/gateway/config.py b/gateway/config.py index 8c467196e3b..87f1c701478 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -130,6 +130,11 @@ def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]: return parsed +def _coerce_dict(value: Any) -> Dict[str, Any]: + """Return *value* when it is a mapping, otherwise an empty dict.""" + return value if isinstance(value, dict) else {} + + def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: """Normalize unauthorized DM behavior to a supported value.""" if isinstance(value, str): @@ -383,6 +388,7 @@ class SessionResetPolicy: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy": + data = _coerce_dict(data) # Handle both missing keys and explicit null values (YAML null → None) mode = data.get("mode") at_hour = data.get("at_hour") @@ -492,24 +498,26 @@ class PlatformConfig: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": + data = _coerce_dict(data) home_channel = None - if "home_channel" in data: + if isinstance(data.get("home_channel"), dict): home_channel = HomeChannel.from_dict(data["home_channel"]) # gateway_restart_notification may be bridged into extra via the # shared-key loop in load_gateway_config(); check both top-level # and extra so YAML ``discord: gateway_restart_notification: false`` # works without needing a separate platforms: block. + extra = _coerce_dict(data.get("extra", {})) _grn = data.get("gateway_restart_notification") if _grn is None: - _grn = data.get("extra", {}).get("gateway_restart_notification") + _grn = extra.get("gateway_restart_notification") # typing_indicator mirrors gateway_restart_notification: it may arrive # top-level or bridged into extra by the shared-key loop in # load_gateway_config(), so check both. _typing = data.get("typing_indicator") if _typing is None: - _typing = data.get("extra", {}).get("typing_indicator") + _typing = extra.get("typing_indicator") channel_overrides: Dict[str, ChannelOverride] = {} raw_overrides = data.get("channel_overrides") or {} @@ -527,7 +535,7 @@ class PlatformConfig: gateway_restart_notification=_coerce_bool(_grn, True), typing_indicator=_coerce_bool(_typing, True), channel_overrides=channel_overrides, - extra=data.get("extra", {}), + extra=extra, ) @@ -586,7 +594,7 @@ class StreamingConfig: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig": - if not data: + if not isinstance(data, dict) or not data: return cls() return cls( enabled=_coerce_bool(data.get("enabled"), False), @@ -823,8 +831,12 @@ class GatewayConfig: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": + data = _coerce_dict(data) platforms = {} - for platform_name, platform_data in data.get("platforms", {}).items(): + platforms_data = _coerce_dict(data.get("platforms", {})) + for platform_name, platform_data in platforms_data.items(): + if not isinstance(platform_data, dict): + continue try: platform = Platform(platform_name) platforms[platform] = PlatformConfig.from_dict(platform_data) @@ -832,11 +844,11 @@ class GatewayConfig: pass # Skip unknown platforms reset_by_type = {} - for type_name, policy_data in data.get("reset_by_type", {}).items(): + for type_name, policy_data in _coerce_dict(data.get("reset_by_type", {})).items(): reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data) reset_by_platform = {} - for platform_name, policy_data in data.get("reset_by_platform", {}).items(): + for platform_name, policy_data in _coerce_dict(data.get("reset_by_platform", {})).items(): try: platform = Platform(platform_name) reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data) @@ -1027,6 +1039,8 @@ def load_gateway_config() -> GatewayConfig: if "stt_echo_transcripts" in yaml_cfg: gw_data["stt_echo_transcripts"] = yaml_cfg["stt_echo_transcripts"] + gateway_cfg = yaml_cfg.get("gateway") + if "group_sessions_per_user" in yaml_cfg: gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"] @@ -1054,7 +1068,11 @@ def load_gateway_config() -> GatewayConfig: if not isinstance(streaming_cfg, dict): # Fall back to nested gateway.streaming written by # ``hermes config set gateway.streaming.*`` - streaming_cfg = yaml_cfg.get("gateway", {}).get("streaming") + streaming_cfg = ( + gateway_cfg.get("streaming") + if isinstance(gateway_cfg, dict) + else None + ) if isinstance(streaming_cfg, dict): gw_data["streaming"] = streaming_cfg @@ -1087,7 +1105,6 @@ def load_gateway_config() -> GatewayConfig: # ``gateway.platforms`` are loaded the same way as top-level # ``platforms``. Merge nested first so top-level config keeps # precedence, matching the existing gateway.streaming fallback. - gateway_cfg = yaml_cfg.get("gateway") gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None platforms_data = gw_data.setdefault("platforms", {}) if not isinstance(platforms_data, dict): diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 5ba09d67492..6287132243a 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -35,6 +35,9 @@ import asyncio import hashlib import hmac import json +from contextlib import contextmanager +from contextvars import ContextVar +from functools import wraps import logging import os import socket as _socket @@ -45,6 +48,12 @@ import uuid from pathlib import Path from typing import Any, Dict, List, Optional +def _approval_event_choices(*, smart_denied: bool, allow_permanent: bool) -> list[str]: + if smart_denied: + return ["once", "deny"] + return ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"] + + try: from aiohttp import web AIOHTTP_AVAILABLE = True @@ -61,6 +70,7 @@ from gateway.platforms.base import ( validate_media_delivery_path, ) from agent.redact import redact_sensitive_text +from gateway.readiness import collect_runtime_readiness logger = logging.getLogger(__name__) @@ -657,6 +667,66 @@ def _openai_error(message: str, err_type: str = "invalid_request_error", param: } +_api_agent_request_reservation: ContextVar[Optional[dict[str, bool]]] = ContextVar( + "api_agent_request_reservation", default=None +) + + +def _admit_api_agent_request(handler): + """Reserve an authenticated API turn before its handler first awaits. + + Gateway shutdown and aiohttp requests share an event loop. Keeping the + drain check and reservation in one non-awaiting block prevents a request + admitted immediately before shutdown from becoming invisible while it is + still parsing its body or resolving session state. The mutable reservation + is intentionally shared with child tasks so agent/task bookkeeping releases + this one slot exactly once. + """ + @wraps(handler) + async def _wrapped(self, request, *args, **kwargs): + auth_err = self._check_auth(request) + if auth_err: + return auth_err + draining = self._draining_response() + if draining is not None: + return draining + reservation = {"active": True} + token = _api_agent_request_reservation.set(reservation) + self._pending_agent_requests += 1 + try: + return await handler(self, request, *args, **kwargs) + finally: + if reservation["active"]: + reservation["active"] = False + self._pending_agent_requests = max(0, self._pending_agent_requests - 1) + _api_agent_request_reservation.reset(token) + + return _wrapped + + +def _release_pending_api_work(adapter, reservation: dict[str, bool]) -> None: + """Release a pending-work reservation exactly once.""" + if reservation["active"]: + reservation["active"] = False + adapter._pending_agent_requests = max(0, adapter._pending_agent_requests - 1) + + +@contextmanager +def _reserve_pending_api_work(adapter): + """Keep externally-triggered background work visible across awaits. + + A handler can detach the reservation to an asyncio task; its done callback + then owns release so shutdown cannot miss the handoff to background work. + """ + reservation = {"active": True, "detached": False} + adapter._pending_agent_requests += 1 + try: + yield reservation + finally: + if not reservation["detached"]: + _release_pending_api_work(adapter, reservation) + + if AIOHTTP_AVAILABLE: @web.middleware async def body_limit_middleware(request, handler): @@ -881,9 +951,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 @@ -898,8 +972,90 @@ 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 + # Requests admitted before their handler reaches agent bookkeeping. + # Shutdown counts this reservation so the request cannot slip through + # the drain between its first await and _run_agent()/task registration. + self._pending_agent_requests: int = 0 + + def active_agent_work_count(self) -> int: + """Return all live agent work owned by this API adapter. + + ``/v1/runs`` registers an asyncio task before it constructs and stores + its agent, so ``_active_run_agents`` has a real queued-before-agent gap. + Reuse the task-based accounting used by the concurrent-run limit: it + covers that gap and excludes completed tasks retained until cleanup. + """ + try: + return ( + int(getattr(self, "_pending_agent_requests", 0)) + + int(self._inflight_agent_runs) + + sum(not task.done() for task in self._active_run_tasks.values()) + ) + except Exception: + return 0 + + @staticmethod + def _gateway_is_draining() -> bool: + """Whether the owning gateway currently refuses new agent turns.""" + try: + from gateway.run import _gateway_runner_ref + + runner = _gateway_runner_ref() + return bool( + runner + and ( + getattr(runner, "_draining", False) + or getattr(runner, "_external_drain_active", False) + ) + ) + except Exception: + return False + + def _draining_response(self) -> Optional["web.Response"]: + """Return a retryable response while the gateway drains existing work.""" + if not self._gateway_is_draining(): + return None + return web.json_response( + _openai_error( + "Gateway is draining existing work; retry shortly.", + code="gateway_draining", + ), + status=503, + headers={"Retry-After": "1"}, + ) + + def _activate_admitted_request(self) -> None: + """Transfer this request's drain reservation to agent bookkeeping.""" + reservation = _api_agent_request_reservation.get() + if reservation and reservation["active"]: + reservation["active"] = False + self._pending_agent_requests = max(0, self._pending_agent_requests - 1) + + def _readiness_work_counts(self) -> tuple[int, int, int]: + """Return bounded work counts from each subsystem's public state.""" + active_api_runs = sum( + 1 + for status in self._run_statuses.values() + if status.get("status") in {"queued", "running", "waiting_for_approval"} + ) + process_depth = 0 + active_delegations = 0 + try: + from tools.process_registry import process_registry + + process_depth = process_registry.completion_queue.qsize() + except Exception: + pass + try: + from tools.async_delegation import active_count + + active_delegations = active_count() + except Exception: + pass + return active_api_runs, process_depth, active_delegations @staticmethod def _parse_cors_origins(value: Any) -> tuple[str, ...]: @@ -1397,8 +1553,19 @@ class APIServerAdapter(BasePlatformAdapter): # This endpoint is served BY the gateway process, so it is by definition # alive — gateway_running is True. Derive busy/drainable from the same # shared contract /api/status uses so the two surfaces never disagree. + active_api_runs, process_depth, active_delegations = self._readiness_work_counts() + from gateway.run import _resolve_gateway_model + + readiness = collect_runtime_readiness( + configured_model=_resolve_gateway_model(), + runtime_status=runtime, + active_api_runs=active_api_runs, + process_completion_queue_depth=process_depth, + active_delegations=active_delegations, + ) return web.json_response({ - "status": "ok", + "status": readiness["status"], + "readiness": readiness, "platform": "hermes-agent", "version": _hermes_version(), "gateway_state": gw_state, @@ -1873,11 +2040,9 @@ class APIServerAdapter(BasePlatformAdapter): fork = db.get_session(fork_id) or {"id": fork_id, "parent_session_id": source_id} return web.json_response({"object": "hermes.session", "session": self._session_response(fork)}, status=201) + @_admit_api_agent_request async def _handle_session_chat(self, request: "web.Request") -> "web.Response": """POST /api/sessions/{session_id}/chat — one synchronous agent turn.""" - auth_err = self._check_auth(request) - if auth_err: - return auth_err gateway_session_key, key_err = self._parse_session_key_header(request) if key_err is not None: return key_err @@ -1917,11 +2082,9 @@ class APIServerAdapter(BasePlatformAdapter): headers=headers, ) + @_admit_api_agent_request async def _handle_session_chat_stream(self, request: "web.Request") -> "web.StreamResponse": """POST /api/sessions/{session_id}/chat/stream — SSE wrapper over _run_agent.""" - auth_err = self._check_auth(request) - if auth_err: - return auth_err gateway_session_key, key_err = self._parse_session_key_header(request) if key_err is not None: return key_err @@ -2058,12 +2221,9 @@ class APIServerAdapter(BasePlatformAdapter): logger.debug("[api_server] session SSE stream error: %s", exc) return response + @_admit_api_agent_request async def _handle_chat_completions(self, request: "web.Request") -> "web.Response": """POST /v1/chat/completions — OpenAI Chat Completions format.""" - auth_err = self._check_auth(request) - if auth_err: - return auth_err - # Bound total in-flight agent runs (configurable; #7483). limited = self._concurrency_limited_response() if limited is not None: @@ -2265,8 +2425,8 @@ class APIServerAdapter(BasePlatformAdapter): # ``tool_progress_callback`` is intentionally not wired here: # it would duplicate every emit because ``run_agent`` fires it # side-by-side with ``tool_start_callback``/``tool_complete_callback``. - # The structured callbacks are strictly richer (they carry the - # tool_call id), so they own the chat-completions SSE channel. + # The structured callbacks are strictly richer (they carry + # the tool_call id), so they own the chat-completions SSE channel. agent_ref = [None] agent_task = asyncio.ensure_future(self._run_agent( user_message=user_message, @@ -3194,12 +3354,9 @@ class APIServerAdapter(BasePlatformAdapter): return response + @_admit_api_agent_request async def _handle_responses(self, request: "web.Request") -> "web.Response": """POST /v1/responses — OpenAI Responses API format.""" - auth_err = self._check_auth(request) - if auth_err: - return auth_err - # Bound total in-flight agent runs (configurable; #7483). limited = self._concurrency_limited_response() if limited is not None: @@ -3742,6 +3899,9 @@ class APIServerAdapter(BasePlatformAdapter): auth_err = self._check_auth(request) if auth_err: return auth_err + draining = self._draining_response() + if draining is not None: + return draining cron_err = self._check_jobs_available() if cron_err: return cron_err @@ -3787,31 +3947,39 @@ class APIServerAdapter(BasePlatformAdapter): self._request_audit_log_suffix(request), ) return web.json_response({"error": "invalid fire token"}, status=401) + draining = self._draining_response() + if draining is not None: + return draining - try: - body = await request.json() - except Exception: - body = {} - job_id = (body or {}).get("job_id") - if not job_id: - return web.json_response({"error": "missing job_id"}, status=400) + with _reserve_pending_api_work(self) as reservation: + try: + body = await request.json() + except Exception: + body = {} + job_id = (body or {}).get("job_id") + if not job_id: + return web.json_response({"error": "missing job_id"}, status=400) - from cron.scheduler_provider import resolve_cron_scheduler - provider = resolve_cron_scheduler() + from cron.scheduler_provider import resolve_cron_scheduler + provider = resolve_cron_scheduler() - loop = asyncio.get_running_loop() - # Fire in the background (202 immediately). fire_due claims via the - # store CAS, so a retry while this is in flight is de-duped. - task = asyncio.create_task( - asyncio.to_thread(provider.fire_due, job_id, adapters=None, loop=loop) - ) - try: - self._background_tasks.add(task) - task.add_done_callback(self._background_tasks.discard) - except (TypeError, AttributeError): - pass + loop = asyncio.get_running_loop() + # Fire in the background (202 immediately). fire_due claims via the + # store CAS, so a retry while this is in flight is de-duped. + task = asyncio.create_task( + asyncio.to_thread(provider.fire_due, job_id, adapters=None, loop=loop) + ) + reservation["detached"] = True + task.add_done_callback( + lambda _task: _release_pending_api_work(self, reservation) + ) + try: + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + except (TypeError, AttributeError): + pass - return web.json_response({"status": "accepted", "job_id": job_id}, status=202) + return web.json_response({"status": "accepted", "job_id": job_id}, status=202) # ------------------------------------------------------------------ @@ -3965,15 +4133,22 @@ class APIServerAdapter(BasePlatformAdapter): """Return a 429 response if the concurrent-run cap is reached, else None. 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. + agent-serving endpoint. Reuse the same adapter-owned work count that + shutdown draining uses, including an admitted request before it reaches + agent/task bookkeeping. 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. """ limit = self._max_concurrent_runs if limit <= 0: return None - inflight = self._inflight_agent_runs + len(self._run_streams) + inflight = self.active_agent_work_count() + # The current request owns one reservation until it hands off to + # _run_agent() or /v1/runs task registration. It must not consume its + # own last available slot; other admitted requests remain counted. + reservation = _api_agent_request_reservation.get() + if reservation and reservation["active"]: + inflight -= 1 if inflight >= limit: return web.json_response( _openai_error( @@ -4091,6 +4266,7 @@ class APIServerAdapter(BasePlatformAdapter): finally: clear_session_vars(tokens) + self._activate_admitted_request() self._inflight_agent_runs += 1 try: return await loop.run_in_executor(None, _run) @@ -4165,12 +4341,9 @@ class APIServerAdapter(BasePlatformAdapter): return _callback + @_admit_api_agent_request async def _handle_runs(self, request: "web.Request") -> "web.Response": """POST /v1/runs — start an agent run, return run_id immediately.""" - auth_err = self._check_auth(request) - if auth_err: - return auth_err - # Long-term memory scope header (see chat_completions for details). gateway_session_key, key_err = self._parse_session_key_header(request) if key_err is not None: @@ -4260,12 +4433,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(), @@ -4288,6 +4468,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, @@ -4312,7 +4504,10 @@ class APIServerAdapter(BasePlatformAdapter): "event": "approval.request", "run_id": run_id, "timestamp": time.time(), - "choices": ["once", "session", "always", "deny"], + "choices": _approval_event_choices( + smart_denied=bool(event.get("smart_denied")), + allow_permanent=event.get("allow_permanent") is not False, + ), }) self._set_run_status( run_id, @@ -4372,12 +4567,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(), @@ -4391,7 +4597,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(), @@ -4412,7 +4618,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(), @@ -4429,7 +4635,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(), @@ -4451,13 +4657,15 @@ 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) + self._activate_admitted_request() task = asyncio.create_task(_run_and_close()) self._active_run_tasks[run_id] = task try: @@ -4508,6 +4716,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, @@ -4535,6 +4744,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) @@ -4643,6 +4853,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: @@ -4650,37 +4861,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 @@ -4689,20 +4892,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 diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 21ca4af6bb4..14a7bc33697 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1072,10 +1072,33 @@ def _profile_cache_roots() -> List[Path]: return roots +def _kanban_attachment_roots() -> List[Path]: + """Return durable Kanban attachment roots without importing kanban_db.""" + override = os.environ.get("HERMES_KANBAN_ATTACHMENTS_ROOT", "").strip() + if override: + return [Path(override).expanduser()] + home_override = os.environ.get("HERMES_KANBAN_HOME", "").strip() + root = Path(home_override).expanduser() if home_override else _HERMES_ROOT + roots = [root / "kanban" / "attachments"] + boards_root = root / "kanban" / "boards" + try: + board_dirs = [ + path for path in boards_root.iterdir() + if path.is_dir() and not path.is_symlink() + and re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", path.name) + and (path / "kanban.db").is_file() + ] + except OSError: + return roots + roots.extend(path / "attachments" for path in board_dirs) + return roots + + def _media_delivery_allowed_roots() -> List[Path]: """Return roots from which model-emitted local media may be delivered.""" roots = [Path(root) for root in MEDIA_DELIVERY_SAFE_ROOTS] roots.extend(_profile_cache_roots()) + roots.extend(_kanban_attachment_roots()) extra_roots = os.environ.get(MEDIA_DELIVERY_ALLOW_DIRS_ENV, "") for chunk in extra_roots.split(os.pathsep): for raw_root in chunk.split(","): diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 2639ab52fdd..5447d99d996 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -278,8 +278,16 @@ class QQAdapter(BasePlatformAdapter): # Connection lifecycle # ------------------------------------------------------------------ - async def connect(self) -> bool: - """Authenticate, obtain gateway URL, and open the WebSocket.""" + async def connect(self, *, is_reconnect: bool = False) -> bool: + """ + Authenticate, obtain gateway URL, and open the WebSocket. + + Args: + is_reconnect: False on a cold first boot; True when the + reconnect watcher is re-establishing this platform after + an outage. QQBot has no server-side update queue so this + flag is accepted for interface conformance only. + """ if not AIOHTTP_AVAILABLE: message = "QQ startup failed: aiohttp not installed" self._set_fatal_error("qq_missing_dependency", message, retryable=True) @@ -2640,7 +2648,10 @@ class QQAdapter(BasePlatformAdapter): return await self.send_with_keyboard( chat_id, build_approval_text(req), - build_approval_keyboard(req.session_key), + build_approval_keyboard( + req.session_key, + allow_permanent=getattr(req, "allow_permanent", True), + ), reply_to=reply_to, ) @@ -2660,6 +2671,8 @@ class QQAdapter(BasePlatformAdapter): session_key: str, description: str = "dangerous command", metadata: Optional[Dict[str, Any]] = None, + allow_permanent: bool = True, + smart_denied: bool = False, ) -> SendResult: """Send a button-based exec-approval prompt for a dangerous command. @@ -2669,6 +2682,8 @@ class QQAdapter(BasePlatformAdapter): adapter's interaction callback (:meth:`_default_interaction_dispatch`). """ del metadata # QQ doesn't have thread_id / DM targeting overrides. + if smart_denied: + description += " Owner override applies to this one operation only." # Use the reply-to message for passive-message context when we have one. # QQ requires a msg_id on outbound messages to a user we've never @@ -2681,6 +2696,7 @@ class QQAdapter(BasePlatformAdapter): description=description, command_preview=command, timeout_sec=self._APPROVAL_TIMEOUT_SECONDS, + allow_permanent=allow_permanent and not smart_denied, ) return await self.send_approval_request( chat_id, req, reply_to=msg_id, diff --git a/gateway/platforms/qqbot/keyboards.py b/gateway/platforms/qqbot/keyboards.py index 19fd36e370d..9c1afbbfa33 100644 --- a/gateway/platforms/qqbot/keyboards.py +++ b/gateway/platforms/qqbot/keyboards.py @@ -201,8 +201,8 @@ def _make_callback_button( ) -def build_approval_keyboard(session_key: str) -> InlineKeyboard: - """Build the 3-button approval keyboard. +def build_approval_keyboard(session_key: str, *, allow_permanent: bool = True) -> InlineKeyboard: + """Build the approval keyboard, hiding persistent scope when unavailable. Layout: ``[✅ 允许一次] [⭐ 始终允许] [❌ 拒绝]`` — all three share ``group_id='approval'`` so clicking one greys out the rest. @@ -210,38 +210,25 @@ def build_approval_keyboard(session_key: str) -> InlineKeyboard: :param session_key: Embedded into ``button_data`` so the decision routes back to the right pending approval. """ - return InlineKeyboard( - content=KeyboardContent( - rows=[ - KeyboardRow(buttons=[ - _make_callback_button( - btn_id="allow", - label="✅ 允许一次", - visited_label="已允许", - data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-once", - style=1, - group_id="approval", - ), - _make_callback_button( - btn_id="always", - label="⭐ 始终允许", - visited_label="已始终允许", - data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-always", - style=1, - group_id="approval", - ), - _make_callback_button( - btn_id="deny", - label="❌ 拒绝", - visited_label="已拒绝", - data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:deny", - style=0, - group_id="approval", - ), - ]), - ] + buttons = [ + _make_callback_button( + btn_id="allow", label="✅ 允许一次", visited_label="已允许", + data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-once", + style=1, group_id="approval", ) - ) + ] + if allow_permanent: + buttons.append(_make_callback_button( + btn_id="always", label="⭐ 始终允许", visited_label="已始终允许", + data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-always", + style=1, group_id="approval", + )) + buttons.append(_make_callback_button( + btn_id="deny", label="❌ 拒绝", visited_label="已拒绝", + data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:deny", + style=0, group_id="approval", + )) + return InlineKeyboard(content=KeyboardContent(rows=[KeyboardRow(buttons=buttons)])) def build_update_prompt_keyboard() -> InlineKeyboard: @@ -295,6 +282,7 @@ class ApprovalRequest: tool_name: str = "" severity: str = "" timeout_sec: int = 120 + allow_permanent: bool = True def build_approval_text(req: ApprovalRequest) -> str: diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index c97122ede1d..b494fc15e5e 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -387,6 +387,20 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return True return super()._open_dm_opted_in() + def _is_interactive_sender_authorized(self, sender_id: str) -> bool: + """Authorize inbound button/list taps before running resolvers. + + Interactive replies bypass the normal ``_build_message_event_from_cloud`` + path (which calls ``_should_process_message``), so approval / + slash-confirm / clarify taps must re-check DM policy here. Uses the + strict ``_is_dm_allowed`` gate (not intake/pairing) so a stale prompt + cannot be answered after the sender is removed from the allowlist. + """ + principal = str(sender_id or "").strip() + if not principal: + return False + return self._is_dm_allowed(principal) + # ------------------------------------------------------------------ lifecycle async def connect(self, *, is_reconnect: bool = False) -> bool: if not check_whatsapp_cloud_requirements(): @@ -805,6 +819,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): session_key: str, description: str = "dangerous command", metadata: Optional[Dict[str, Any]] = None, + allow_permanent: bool = True, + smart_denied: bool = False, ) -> SendResult: """Render a dangerous-command approval prompt with native buttons. @@ -816,6 +832,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if self._http_client is None: return SendResult(success=False, error="Not connected") + del allow_permanent # This adapter already offers one-shot Approve / Deny only. # WhatsApp body caps at 1024 chars; reserve room for the # framing prose around the command. cmd = command or "" @@ -824,6 +841,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): f"⚠️ *Command Approval Required*\n\n" f"```\n{cmd_preview}\n```\n\n" f"Reason: {description}" + + ("\n\nSmart DENY: owner override applies to this one operation only." if smart_denied else "") ) approval_id = uuid.uuid4().hex[:12] @@ -1635,6 +1653,18 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if not button_id: return False + sender_id = str(raw_message.get("from") or "").strip() + if not self._is_interactive_sender_authorized(sender_id): + logger.warning( + "[whatsapp_cloud] Rejected unauthorized interactive tap " + "from %s (button_id=%r)", + sender_id or "<unknown>", + button_id, + ) + # Claim the webhook entry so the tap is not re-dispatched as + # plain text (which could re-enter the agent loop). + return True + # Clarify: cl:<clarify_id>:<idx|other> if button_id.startswith("cl:"): parts = button_id.split(":", 2) diff --git a/gateway/readiness.py b/gateway/readiness.py new file mode 100644 index 00000000000..379e074bb40 --- /dev/null +++ b/gateway/readiness.py @@ -0,0 +1,117 @@ +"""Bounded, non-destructive readiness probes for authenticated health surfaces.""" + +from __future__ import annotations + +import shutil +import sqlite3 +from pathlib import Path +from typing import Any + +import yaml + +from hermes_constants import get_hermes_home + + +_DISK_DEGRADED_PERCENT = 90.0 + + +def _check(status: str, detail: str | None = None, **extra: Any) -> dict[str, Any]: + result: dict[str, Any] = {"status": status} + if detail: + result["detail"] = detail + result.update(extra) + return result + + +def _probe_state_db(home: Path) -> dict[str, Any]: + path = home / "state.db" + if not path.exists(): + return _check("ok", "not initialized") + try: + # A readiness probe must never compete with normal state writers. A + # read-only schema query still catches unreadable/corrupt databases + # without taking a write reservation on every health poll. + uri = f"file:{path.as_posix()}?mode=ro" + with sqlite3.connect(uri, uri=True, timeout=1.0) as conn: + conn.execute("PRAGMA query_only = ON") + conn.execute("SELECT name FROM sqlite_master LIMIT 1").fetchone() + return _check("ok") + except Exception as exc: + return _check("degraded", type(exc).__name__) + + +def _probe_config(home: Path) -> dict[str, Any]: + path = home / "config.yaml" + if not path.exists(): + return _check("ok", "using defaults") + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if raw is not None and not isinstance(raw, dict): + return _check("degraded", "top level is not a mapping") + return _check("ok") + except Exception as exc: + return _check("degraded", f"invalid config ({type(exc).__name__})") + + +def _probe_disk(home: Path) -> dict[str, Any]: + try: + usage = shutil.disk_usage(home) + used_pct = round((usage.used / usage.total) * 100, 1) if usage.total else 0.0 + status = "degraded" if used_pct >= _DISK_DEGRADED_PERCENT else "ok" + return _check(status, used_percent=used_pct, free_bytes=usage.free) + except Exception as exc: + return _check("degraded", type(exc).__name__) + + +def _probe_gateway(runtime_status: dict[str, Any]) -> dict[str, Any]: + state = str(runtime_status.get("gateway_state") or "unknown") + platforms = runtime_status.get("platforms") + connected = 0 + configured = 0 + if isinstance(platforms, dict): + configured = len(platforms) + connected = sum( + 1 + for value in platforms.values() + if isinstance(value, dict) + and str(value.get("state") or value.get("status") or "").lower() + in {"connected", "running", "ok"} + ) + status = "ok" if state in {"running", "draining"} else "degraded" + return _check(status, state=state, connected_platforms=connected, platforms=configured) + + +def collect_runtime_readiness( + *, + configured_model: str, + runtime_status: dict[str, Any] | None, + active_api_runs: int = 0, + process_completion_queue_depth: int = 0, + active_delegations: int = 0, +) -> dict[str, Any]: + """Return bounded readiness diagnostics without mutating runtime state. + + The detailed health endpoint is authenticated. Even there, probes expose + status and counts only: never config values, credentials, paths, commands, + queue payloads, or exception messages. + """ + home = get_hermes_home() + runtime = runtime_status if isinstance(runtime_status, dict) else {} + checks = { + "state_db": _probe_state_db(home), + "config": _probe_config(home), + "model": _check("ok" if str(configured_model or "").strip() else "degraded"), + "disk": _probe_disk(home), + "gateway": _probe_gateway(runtime), + "background_queues": _check( + "ok", + active_api_runs=max(0, int(active_api_runs)), + process_completions=max(0, int(process_completion_queue_depth)), + active_delegations=max(0, int(active_delegations)), + ), + } + overall = "ok" if all(item.get("status") == "ok" for item in checks.values()) else "degraded" + return {"status": overall, "checks": checks} + + +__all__ = ["collect_runtime_readiness"] diff --git a/gateway/relay/__init__.py b/gateway/relay/__init__.py index 0c64aaedeb5..87326c4e4a0 100644 --- a/gateway/relay/__init__.py +++ b/gateway/relay/__init__.py @@ -36,7 +36,8 @@ def relay_url() -> Optional[str]: from gateway.run import _load_gateway_config # late import to avoid cycle cfg = _load_gateway_config() - url = (cfg.get("gateway") or {}).get("relay_url", "").strip() + url = (cfg.get("gateway") or {}).get("relay_url") + url = (url or "").strip() if url: return url.rstrip("/") except Exception: # noqa: BLE001 - config absence/parse must never crash registration diff --git a/gateway/run.py b/gateway/run.py index 4370a084eb1..d5b3fbff474 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -353,6 +353,34 @@ def _redact_approval_command(cmd: "str | None") -> str: return redact_sensitive_text(str(cmd or ""), force=True) +def _format_exec_approval_fallback( + command: str, + description: str, + command_prefix: str, + *, + allow_permanent: bool = True, + smart_denied: bool = False, +) -> str: + """Render the text fallback from approval capabilities, not platform names.""" + cmd_preview = command[:200] + "..." if len(command) > 200 else command + heading = "⚠️ **Dangerous command requires approval:**" + if smart_denied: + heading = "⚠️ **Smart DENY — owner override for one operation:**" + + choices = [f"Reply `{command_prefix}approve` to execute this one operation"] + if not smart_denied: + choices.append( + f"`{command_prefix}approve session` to approve this pattern for the session" + ) + if allow_permanent: + choices.append(f"`{command_prefix}approve always` to approve permanently") + choices.append(f"`{command_prefix}deny` to cancel") + return ( + f"{heading}\n```\n{cmd_preview}\n```\nReason: {description}\n\n" + + ", ".join(choices[:-1]) + f", or {choices[-1]}." + ) + + def _gateway_provider_error_reply(text: str) -> str: """Map raw provider/API errors to a short user-safe Telegram reply.""" if _GATEWAY_AUTH_ERROR_RE.search(text): @@ -1744,6 +1772,7 @@ from gateway.config import ( load_gateway_config, ) from gateway.session import ( + AsyncSessionStore, SessionStore, SessionSource, SessionContext, @@ -2853,6 +2882,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew key, max_active_age=_bg_max_age_seconds, ), ) + # One enforced loop-side boundary for the synchronous SessionStore. + # Sync helpers keep using ``session_store`` directly; async gateway + # handlers call this facade and await every operation. + self._async_session_store = AsyncSessionStore(self.session_store) self.delivery_router = DeliveryRouter(self.config) self._running = False self._gateway_loop: Optional[asyncio.AbstractEventLoop] = None @@ -2946,6 +2979,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # cannot grow unbounded over a long-running gateway lifetime. self._session_sources: "OrderedDict[str, SessionSource]" = OrderedDict() self._session_sources_max = 512 + # Completion delivery is intentionally lifecycle-scoped. This closes + # duplicate queue/watcher races inside one gateway without pretending + # the adapter call and a persistence write can be exactly-once across + # a process crash. Any durable async-delegation replay state remains + # owned by tools.async_delegation, not a parallel gateway ledger. + self._completion_delivery_lock = threading.Lock() + self._completion_deliveries_inflight: set[tuple[str, str, object]] = set() + self._completion_deliveries_delivered: "OrderedDict[tuple[str, str, object], None]" = OrderedDict() + self._completion_delivery_retention = 2048 # Cache AIAgent instances per session to preserve prompt caching. # Without this, a new AIAgent is created per message, rebuilding the @@ -4080,6 +4122,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew def _running_agent_count(self) -> int: return len(self._running_agents) + def _active_work_count(self) -> int: + """All agent work the gateway must expose and drain as one total.""" + return ( + self._running_agent_count() + + self._active_cron_job_count() + + self._active_api_run_count() + ) + def _active_cron_job_count(self) -> int: """Count of cron jobs currently executing, from the cron scheduler's own in-flight tracking (``cron.scheduler._running_job_ids``). @@ -4100,6 +4150,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: return 0 + def _active_api_run_count(self) -> int: + """Count API-server work that is outside ``_running_agents``. + + The primary API server owns the sole HTTP listener. Secondary multiplex + profiles cannot create an ``api_server`` adapter because it binds a port, + so only the primary registry is a supported source of this work. + """ + try: + adapter = getattr(self, "adapters", {}).get(Platform.API_SERVER) + helper = getattr(adapter, "active_agent_work_count", None) + return max(0, int(helper())) if callable(helper) else 0 + except Exception: + return 0 + # ── scale-to-zero idle detection / dormant-quiesce (Phase 0) ────────────── # The gateway-side BEHAVIOUR that consumes the relay scale-to-zero primitives # (gateway-gateway Phase 5). Pure logic lives in gateway/scale_to_zero.py; the @@ -4487,7 +4551,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew gateway_state=gateway_state, exit_reason=exit_reason, restart_requested=self._restart_requested, - active_agents=self._running_agent_count(), + active_agents=self._active_work_count(), ) except Exception: pass @@ -4510,7 +4574,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew """ try: from gateway.status import write_runtime_status - write_runtime_status(active_agents=self._running_agent_count()) + write_runtime_status(active_agents=self._active_work_count()) except Exception: pass @@ -4537,7 +4601,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.info( "External drain ENGAGED (.drain_request.json present) — refusing " "new turns; %d in-flight turn(s) will finish. Process stays up.", - self._running_agent_count(), + self._active_work_count(), ) # Flip the persisted lifecycle state so /api/status.gateway_busy / # gateway_drainable track the drain. Preserve active_agents (the @@ -4588,6 +4652,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew try: if drain_requested(): self._enter_external_drain() + # API and cron work live outside messaging's + # _running_agents map. Refresh the aggregate while an + # external caller polls this reversible drain state. + self._persist_active_agents() else: self._exit_external_drain() except asyncio.CancelledError: @@ -4787,7 +4855,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew """Load reasoning effort from config.yaml. Reads agent.reasoning_effort from config.yaml. Valid: "none", - "minimal", "low", "medium", "high", "xhigh". Returns None to use + "minimal", "low", "medium", "high", "xhigh", "max", "ultra". Returns None to use default (medium). """ from hermes_constants import parse_reasoning_effort @@ -5008,6 +5076,74 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew pass return None + def _refresh_fallback_model(self) -> list | None: + """Re-read fallback_providers from disk for the next agent create/reuse. + + Cron already does this per job via ``get_fallback_chain``; the gateway + previously froze ``self._fallback_model`` at process start, so a chain + configured (or changed) after ``hermes gateway`` was running never + reached messaging sessions even though the same process's cron jobs + fell back correctly. Fixes #60955. + + A TRANSIENT read/parse failure (user mid-edit of config.yaml with a + non-atomic write) keeps the last known-good chain instead of wiping a + cached agent's working fallback for that turn. Only a successful read + that genuinely lacks the key clears the chain. + """ + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if not cfg_path.exists(): + self._fallback_model = None + return self._fallback_model + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + except Exception: + # Transient failure — keep last known-good chain. + logger.debug( + "fallback_providers refresh: config.yaml read failed; " + "keeping last known-good chain", exc_info=True, + ) + return self._fallback_model + self._fallback_model = get_fallback_chain(cfg) or None + return self._fallback_model + + @staticmethod + def _apply_fallback_chain_to_agent(agent: Any, chain: list | None) -> None: + """Keep a cached agent's fallback chain aligned with current config. + + Skips rewrite while a cooldown is holding the agent on an already- + activated fallback provider — ``restore_primary_runtime`` owns that + turn-scoped lifecycle. When primary is active (or cooldown expired), + replace the chain so mid-uptime ``fallback_providers`` edits take + effect without requiring a gateway restart (#60955). + """ + if agent is None: + return + new_chain = list(chain or []) + rate_limited_until = getattr(agent, "_rate_limited_until", 0) or 0 + if ( + getattr(agent, "_fallback_activated", False) + and rate_limited_until > time.monotonic() + ): + return + old_chain = list(getattr(agent, "_fallback_chain", []) or []) + agent._fallback_chain = new_chain + agent._fallback_model = new_chain[0] if new_chain else None + if not getattr(agent, "_fallback_activated", False): + agent._fallback_index = 0 + # A config edit signals the user changed something — drop the + # session-scoped unavailability memo so re-configured entries + # (e.g. credentials added mid-uptime for a previously-failing + # provider) get retried instead of staying suppressed for the + # cached agent's lifetime. Only on actual content change, so + # the per-message no-op refresh keeps the memo's rate-limiting + # benefit (#60955). + if new_chain != old_chain: + unavailable = getattr(agent, "_unavailable_fallback_keys", None) + if unavailable: + unavailable.clear() + def _snapshot_running_agents(self) -> Dict[str, Any]: return { session_key: agent @@ -5106,7 +5242,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: return False - def _session_has_compression_in_flight(self, session_key: str) -> bool: + async def _session_has_compression_in_flight(self, session_key: str) -> bool: """Return True when a compression lock is held for this session's id. Context compression is interrupt-protected (#23975) but gateway @@ -5114,28 +5250,43 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew the pre-rotation parent while compression is mid-flight, producing orphaned compression siblings (#56391). Callers demote interrupt to queue when this returns True. + + Both blocking sources — the ``session_store`` lock + JSON load, and the + SQLite ``get_compression_lock_holder`` SELECT — are offloaded to a + worker thread so a large state.db never freezes the event loop (#5). """ session_store = getattr(self, "session_store", None) if not session_key or session_store is None: return False try: - with session_store._lock: # noqa: SLF001 — snapshot entry under lock - session_store._ensure_loaded_locked() # noqa: SLF001 - entry = session_store._entries.get(session_key) # noqa: SLF001 - session_id = getattr(entry, "session_id", None) if entry is not None else None - if not session_id: - return False + session_id = await asyncio.to_thread( + self._lookup_session_id_under_store_lock, session_store, session_key + ) except Exception: return False + if not session_id: + return False session_db = getattr(self, "_session_db", None) if session_db is None: return False - db = getattr(session_db, "_db", session_db) + raw_db = getattr(session_db, "_db", session_db) try: - return bool(db.get_compression_lock_holder(str(session_id))) + holder = await asyncio.to_thread( + raw_db.get_compression_lock_holder, str(session_id) + ) + return bool(holder) except Exception: return False + @staticmethod + def _lookup_session_id_under_store_lock(session_store, session_key: str): + """Sync helper run in the thread pool: read session_id under the store lock.""" + # noqa: SLF001 — intentional private access; runs off the event loop. + with session_store._lock: # noqa: SLF001 + session_store._ensure_loaded_locked() # noqa: SLF001 + entry = session_store._entries.get(session_key) # noqa: SLF001 + return getattr(entry, "session_id", None) if entry is not None else None + # Hard cap on per-session pending follow-ups for busy_input_mode=queue # (and the draining/steer-fallback/subagent-demotion paths that share # this entry point). Without a cap, a stuck agent + a rapid-fire user @@ -5358,7 +5509,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew effective_mode = "queue" demoted_for_compression = ( effective_mode == "interrupt" - and self._session_has_compression_in_flight(session_key) + and await self._session_has_compression_in_flight(session_key) ) if demoted_for_compression: logger.info( @@ -5571,22 +5722,26 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew snapshot = self._snapshot_running_agents() last_active_count = self._running_agent_count() last_cron_count = self._active_cron_job_count() + last_api_count = self._active_api_run_count() last_status_at = 0.0 def _maybe_update_status(force: bool = False) -> None: - nonlocal last_active_count, last_cron_count, last_status_at + nonlocal last_active_count, last_cron_count, last_api_count, last_status_at now = asyncio.get_running_loop().time() active_count = self._running_agent_count() cron_count = self._active_cron_job_count() + api_count = self._active_api_run_count() if ( force or active_count != last_active_count or cron_count != last_cron_count + or api_count != last_api_count or (now - last_status_at) >= 1.0 ): self._update_runtime_status("draining") last_active_count = active_count last_cron_count = cron_count + last_api_count = api_count last_status_at = now # Cron jobs run on the scheduler's own thread pool, outside @@ -5594,7 +5749,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # same wait/timeout this method already applies to chat sessions, # or a cron job's tool work gets killed with zero warning the # instant it's the only active thing running (#60432). - if not self._running_agents and last_cron_count == 0: + # API-server / desk sessions have the same structural gap (#63529). + if not self._running_agents and last_cron_count == 0 and last_api_count == 0: _maybe_update_status(force=True) return snapshot, False @@ -5604,12 +5760,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew deadline = asyncio.get_running_loop().time() + timeout while ( - (self._running_agents or self._active_cron_job_count()) + ( + self._running_agents + or self._active_cron_job_count() + or self._active_api_run_count() + ) and asyncio.get_running_loop().time() < deadline ): _maybe_update_status() await asyncio.sleep(0.1) - timed_out = bool(self._running_agents) or bool(self._active_cron_job_count()) + timed_out = ( + bool(self._running_agents) + or bool(self._active_cron_job_count()) + or bool(self._active_api_run_count()) + ) _maybe_update_status(force=True) return snapshot, timed_out @@ -5647,7 +5811,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew source = None try: if getattr(self, "session_store", None) is not None: - self.session_store._ensure_loaded() + await self.async_session_store._ensure_loaded() entry = self.session_store._entries.get(session_key) source = getattr(entry, "origin", None) if entry else None except Exception as e: @@ -6917,7 +7081,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew pass else: try: - suspended = self.session_store.suspend_recently_active() + suspended = await self.async_session_store.suspend_recently_active() if suspended: logger.info("Marked %d in-flight session(s) as resumable from previous run", suspended) except Exception as e: @@ -7478,13 +7642,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Make sure there's an entry in the session_store for this key. If # the home channel has never been used, get_or_create_session # creates one; switch_session then re-points it. - self.session_store.get_or_create_session(dest_source) + await self.async_session_store.get_or_create_session(dest_source) # Re-bind the destination key to the CLI session_id. switch_session # ends the prior session in SQLite and reopens the CLI session under # the new key. The CLI's transcript becomes the active one for the # gateway from this moment on. - switched = self.session_store.switch_session(session_key, cli_session_id) + switched = await self.async_session_store.switch_session(session_key, cli_session_id) if switched is None: raise RuntimeError( f"could not switch session key {session_key} → {cli_session_id}" @@ -7561,13 +7725,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _MAX_FINALIZE_RETRIES = 3 while self._running: try: - self.session_store._ensure_loaded() + await self.async_session_store._ensure_loaded() # Collect expired sessions first, then log a single summary. _expired_entries = [] for key, entry in list(self.session_store._entries.items()): if entry.expiry_finalized: continue - if not self.session_store._is_session_expired(entry): + if not await self.async_session_store._is_session_expired(entry): continue _expired_entries.append((key, entry)) @@ -7651,7 +7815,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # state.db (single write-path, #9006) — also drops # the persisted /model override, since finalization # is a conversation boundary. - self.session_store.set_expiry_finalized(entry) + await self.async_session_store.set_expiry_finalized(entry) logger.debug( "Session expiry finalized for %s", entry.session_id, @@ -7666,7 +7830,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "Marking as finalized to prevent infinite retry loop.", failures, entry.session_id, e, ) - self.session_store.set_expiry_finalized( + await self.async_session_store.set_expiry_finalized( entry, clear_model_override=False ) _finalize_failures.pop(entry.session_id, None) @@ -7719,7 +7883,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew getattr(self.config, "session_store_max_age_days", 0) or 0 ) if _max_age > 0: - _pruned = self.session_store.prune_old_entries(_max_age) + _pruned = await self.async_session_store.prune_old_entries(_max_age) if _pruned: logger.info( "SessionStore prune: dropped %d stale entries", @@ -8053,7 +8217,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if _agent is _AGENT_PENDING_SENTINEL: continue try: - self.session_store.mark_resume_pending( + await self.async_session_store.mark_resume_pending( _sk, "restart_timeout" if self._restart_requested else "shutdown_timeout", ) @@ -8062,12 +8226,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.debug("pre-drain mark_resume_pending failed for %s: %s", _sk, _e) _cron_at_start = self._active_cron_job_count() + _api_at_start = self._active_api_run_count() _drain_started_at = time.monotonic() active_agents, timed_out = await self._drain_active_agents(timeout) logger.info( "Shutdown phase: drain done at +%.2fs (drain took %.2fs, " "timed_out=%s, active_at_start=%d, active_now=%d, " - "cron_at_start=%d, cron_now=%d)", + "cron_at_start=%d, cron_now=%d, " + "api_at_start=%d, api_now=%d)", _phase_elapsed(), time.monotonic() - _drain_started_at, timed_out, @@ -8075,6 +8241,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew self._running_agent_count(), _cron_at_start, self._active_cron_job_count(), + _api_at_start, + self._active_api_run_count(), ) if not timed_out: @@ -8084,7 +8252,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew for _sk in _pre_drain_keys: if _sk not in self._running_agents: try: - self.session_store.clear_resume_pending(_sk) + await self.async_session_store.clear_resume_pending(_sk) except Exception as _e: logger.debug( "clear_resume_pending after drain failed for %s: %s", @@ -8093,11 +8261,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if timed_out: logger.warning( - "Gateway drain timed out after %.1fs with %d active agent(s) " - "and %d in-flight cron job(s); interrupting remaining work.", + "Gateway drain timed out after %.1fs with %d active agent(s), " + "%d in-flight cron job(s), and %d api_server run(s); " + "interrupting remaining work.", timeout, self._running_agent_count(), self._active_cron_job_count(), + self._active_api_run_count(), ) # Mark forcibly-interrupted sessions as resume_pending BEFORE # interrupting the agents. This preserves each session's @@ -8127,7 +8297,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if _agent is _AGENT_PENDING_SENTINEL: continue try: - self.session_store.mark_resume_pending(_sk, _resume_reason) + await self.async_session_store.mark_resume_pending(_sk, _resume_reason) except Exception as _e: logger.debug( "mark_resume_pending failed for %s: %s", @@ -9500,7 +9670,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # the id out from under it, forking orphaned compression # siblings. Demote to queue semantics so the follow-up waits # for the in-flight compression + rotation to land. - if self._session_has_compression_in_flight(_quick_key): + if await self._session_has_compression_in_flight(_quick_key): logger.info( "PRIORITY interrupt demoted to queue for session %s " "because context compression is in flight (#56391)", @@ -9541,7 +9711,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if isinstance(quick_commands, dict) and command in quick_commands: qcmd = quick_commands[command] if qcmd.get("type") == "alias": - target = qcmd.get("target", "").strip() + target = (qcmd.get("target") or "").strip() if target: target = target if target.startswith("/") else f"/{target}" target_command = target.lstrip("/") @@ -9953,7 +10123,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew else: return f"Quick command '/{command}' has no command defined." elif qcmd.get("type") == "alias": - target = qcmd.get("target", "").strip() + target = (qcmd.get("target") or "").strip() if target: target = target if target.startswith("/") else f"/{target}" target_command = target.lstrip("/") @@ -10207,7 +10377,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # on error. Let the user drive the next turn. if _final_text.strip(): try: - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) except Exception: session_entry = None if session_entry is not None: @@ -10523,8 +10693,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew from agent.model_metadata import get_model_context_length_async _msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~")) - _msg_runtime = _resolve_runtime_agent_kwargs() _msg_config_ctx = None + _msg_cfg = None + _msg_model_cfg = {} + _msg_custom_providers = [] try: _msg_cfg = _load_gateway_config() _msg_model_cfg = _msg_cfg.get("model", {}) @@ -10532,13 +10704,57 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _msg_raw_ctx = _msg_model_cfg.get("context_length") if _msg_raw_ctx is not None: _msg_config_ctx = int(_msg_raw_ctx) + try: + from hermes_cli.config import get_compatible_custom_providers + + _msg_custom_providers = get_compatible_custom_providers(_msg_cfg) + except Exception: + _msg_custom_providers = _msg_cfg.get("custom_providers") or [] except Exception: pass + # Resolve the session's actual model/provider/base_url the + # same way the hygiene compression block does (~11080). + # GatewayRunner has no self._model/self._base_url attrs + # (that was copy-pasted from HermesCLI, which does carry + # self.model/self.base_url), so using them here always raised + # AttributeError, silently caught below, meaning this feature + # never ran. + _msg_model, _msg_runtime = self._resolve_session_agent_runtime( + source=source, + session_key=session_key, + user_config=_msg_cfg, + ) + _msg_base_url = _msg_runtime.get("base_url") or "" + # A global model.context_length belongs to the configured + # model, not a session /model or channel override. Prefer a + # matching per-custom-provider model limit when available. + _msg_configured_model = ( + _msg_model_cfg.get("default") or _msg_model_cfg.get("model") + if isinstance(_msg_model_cfg, dict) + else _msg_model_cfg + ) + if _msg_model != _msg_configured_model: + _msg_config_ctx = None + if _msg_custom_providers and _msg_base_url: + try: + from hermes_cli.config import get_custom_provider_context_length + + _msg_custom_ctx = get_custom_provider_context_length( + model=_msg_model, + base_url=_msg_base_url, + custom_providers=_msg_custom_providers, + ) + if _msg_custom_ctx: + _msg_config_ctx = _msg_custom_ctx + except Exception: + pass _msg_ctx_len = await get_model_context_length_async( - self._model, - base_url=self._base_url or _msg_runtime.get("base_url") or "", + _msg_model, + base_url=_msg_base_url, api_key=_msg_runtime.get("api_key") or "", config_context_length=_msg_config_ctx, + provider=_msg_runtime.get("provider") or "", + custom_providers=_msg_custom_providers, ) _ctx_result = await preprocess_context_references_async( message_text, @@ -10557,10 +10773,35 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if _ctx_result.expanded: message_text = _ctx_result.message except Exception as exc: - logger.debug("@ context reference expansion failed: %s", exc) + logger.warning("@ context reference expansion failed: %s", exc) + logger.debug("@ context reference expansion failure detail", exc_info=True) return message_text + async def _prepare_profile_scoped_inbound_message_text( + self, + *, + event: MessageEvent, + source: SessionSource, + history: List[Dict[str, Any]], + session_key: Optional[str] = None, + ) -> Optional[str]: + """Run inbound preprocessing under the routed profile when multiplexed.""" + if getattr(getattr(self, "config", None), "multiplex_profiles", False): + with _profile_runtime_scope(self._resolve_profile_home_for_source(source)): + return await self._prepare_inbound_message_text( + event=event, + source=source, + history=history, + session_key=session_key, + ) + return await self._prepare_inbound_message_text( + event=event, + source=source, + history=history, + session_key=session_key, + ) + def _consume_pending_native_image_paths(self, session_key: str) -> List[str]: pending_native = getattr(self, "_pending_native_image_paths_by_session", None) if not pending_native: @@ -10588,6 +10829,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: pass + @property + def async_session_store(self) -> AsyncSessionStore: + """Return the single async facade for this runner's SessionStore.""" + facade = getattr(self, "_async_session_store", None) + if facade is None or facade._store is not self.session_store: + facade = AsyncSessionStore(self.session_store) + self._async_session_store = facade + return facade + def _get_cached_session_source(self, session_key: str): if not session_key: return None @@ -10631,7 +10881,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: pass - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) session_key = session_entry.session_key pinned_session_id = str( (getattr(event, "metadata", None) or {}).get("gateway_session_id") or "" @@ -10663,7 +10913,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) return prior_session_id = session_entry.session_id - switched = self.session_store.switch_session(session_key, pinned_session_id) + switched = await self.async_session_store.switch_session(session_key, pinned_session_id) if switched is not None: session_entry = switched logger.info( @@ -10713,7 +10963,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # lane session is ended cleanly. Mutating session_entry in # place here created a split-brain state where the JSON # index pointed at one id but code downstream used another. - switched = self.session_store.switch_session(session_key, bound_session_id) + switched = await self.async_session_store.switch_session(session_key, bound_session_id) if switched is not None: session_entry = switched # If the stored binding pointed at a parent, rewrite it to the @@ -10901,7 +11151,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.warning("[Gateway] Failed to auto-load skill(s) %s: %s", _skill_names, e) # Load conversation history from transcript - history = self.session_store.load_transcript(session_entry.session_id) + history = await self.async_session_store.load_transcript(session_entry.session_id) # ----------------------------------------------------------------- # Session hygiene: auto-compress pathologically large transcripts @@ -11164,7 +11414,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) if _hyg_rotated: session_entry.session_id = _hyg_new_sid - self.session_store._save() + await self.async_session_store._save() await asyncio.to_thread( self._sync_telegram_topic_binding, source, session_entry, @@ -11172,7 +11422,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) # Only rewrite the transcript when rotation produced - # a NEW session id OR in-place compaction succeeded. + # a NEW session id. In-place compaction does NOT + # need a rewrite: archive_and_compact() has already + # soft-archived the previous active rows and inserted + # the compacted messages as the new active set inside + # _compress_context(). Calling rewrite_transcript() + # after in-place compaction would invoke + # replace_messages(active_only=False) which DELETEs + # ALL rows — including the archived turns that + # archive_and_compact() deliberately preserved + # (silent data loss, #61145). + # # The danger this guards against (mirrors the # /compress fix #44794/#39704): if _compress_context # returns a summary but neither rotates nor completes @@ -11181,8 +11441,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # rewrite_transcript() would DELETE the original # messages and replace them with only the compressed # summary (permanent data loss, #21301). - if _hyg_rotated or _hyg_in_place: - self.session_store.rewrite_transcript( + if _hyg_rotated: + await self.async_session_store.rewrite_transcript( session_entry.session_id, _compressed ) # Reset stored token count — transcript rewritten @@ -11192,6 +11452,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _new_tokens = estimate_messages_tokens_rough( _compressed ) + elif _hyg_in_place: + # archive_and_compact() already persisted the + # compacted transcript inside _compress_context. + # Reset counts to match the new active set. + session_entry.last_prompt_tokens = 0 + history = _compressed + _new_count = len(_compressed) + _new_tokens = estimate_messages_tokens_rough( + _compressed + ) else: # No rewrite happened — transcript preserved # unchanged, so the post-compression counts equal @@ -11289,7 +11559,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) # First-message onboarding -- only on the very first interaction ever - if not history and not self.session_store.has_any_sessions(): + if not history and not await self.async_session_store.has_any_sessions(): # Default first-contact note: a brief self-introduction. _intro_note = ( "\n\n[System note: This is the user's very first message ever. " @@ -11374,7 +11644,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # attachments (documents, audio, etc.) are not sent to the vision # tool even when they appear in the same message. # ----------------------------------------------------------------- - message_text = await self._prepare_inbound_message_text( + message_text = await self._prepare_profile_scoped_inbound_message_text( event=event, source=source, history=history, @@ -11533,7 +11803,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if session_key and _should_clear_resume_pending_after_turn(agent_result): self._clear_restart_failure_count(session_key) try: - self.session_store.clear_resume_pending(session_key) + await self.async_session_store.clear_resume_pending(session_key) except Exception as _e: logger.debug( "clear_resume_pending failed for %s: %s", @@ -11555,8 +11825,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id: if session_entry.session_id == _run_start_session_id: session_entry.session_id = agent_result["session_id"] - self.session_store._save() - self.session_store._record_gateway_session_peer( + await self.async_session_store._save() + await self.async_session_store._record_gateway_session_peer( session_entry.session_id, session_key, source, @@ -11754,7 +12024,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "Auto-resetting session %s after compression exhaustion.", session_entry.session_id, ) - new_entry = self.session_store.reset_session(session_key) + new_entry = await self.async_session_store.reset_session(session_key) self._evict_cached_agent(session_key) self._session_model_overrides.pop(session_key, None) self._set_session_reasoning_override(session_key, None) @@ -11798,7 +12068,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew pass # Skip all transcript writes — don't grow a broken session elif not history: tool_defs = agent_result.get("tools", []) - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, { "role": "session_meta", @@ -11852,7 +12122,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # after transient failures). #47237 _skip_persist = ( event.message_id - and self.session_store.has_platform_message_id( + and await self.async_session_store.has_platform_message_id( session_entry.session_id, str(event.message_id) ) ) @@ -11863,7 +12133,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew event.message_id, session_entry.session_id, ) else: - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, _user_entry, skip_db=agent_persisted, @@ -11889,13 +12159,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew } if event.message_id: _user_entry["message_id"] = str(event.message_id) - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, _user_entry, skip_db=agent_persisted, ) if response: - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, {"role": "assistant", "content": response, "timestamp": ts}, skip_db=agent_persisted, @@ -11920,7 +12190,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ): entry["message_id"] = str(event.message_id) _user_msg_id_attached = True - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, entry, skip_db=agent_persisted, ) @@ -11928,7 +12198,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Token counts and model are now persisted by the agent directly. # Keep only last_prompt_tokens here for context-window tracking and # compression decisions. - self.session_store.update_session( + await self.async_session_store.update_session( session_entry.session_key, last_prompt_tokens=agent_result.get("last_prompt_tokens", 0), ) @@ -12028,7 +12298,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if 'message_text' in locals() and message_text is not None and session_entry is not None: _already_persisted = False try: - _recent_transcript = self.session_store.load_transcript(session_entry.session_id) + _recent_transcript = await self.async_session_store.load_transcript(session_entry.session_id) except Exception: _recent_transcript = [] for _msg in reversed(_recent_transcript[-10:]): @@ -12056,7 +12326,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew } if getattr(event, "message_id", None): _user_entry["message_id"] = str(event.message_id) - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, _user_entry, ) @@ -12511,7 +12781,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: return 20 - def _get_goal_manager_for_event(self, event: "MessageEvent"): + async def _get_goal_manager_for_event(self, event: "MessageEvent"): """Return a GoalManager bound to the session for this gateway event. Returns ``(manager, session_entry)`` or ``(None, None)`` if the @@ -12523,7 +12793,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.debug("goal manager unavailable: %s", exc) return None, None try: - session_entry = self.session_store.get_or_create_session(event.source) + session_entry = await self.async_session_store.get_or_create_session(event.source) except Exception as exc: logger.debug("goal manager: session lookup failed: %s", exc) return None, None @@ -13244,7 +13514,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew chat_type=source.chat_type, thread_id=source.thread_id, session_db=getattr(self._session_db, "_db", self._session_db), - fallback_model=self._fallback_model, + # Reload from disk — do not reuse the startup snapshot (#60955). + fallback_model=self._refresh_fallback_model(), ) try: return agent.run_conversation( @@ -13965,8 +14236,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "content": f"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]", } try: - session_entry = self.session_store.get_or_create_session(event.source) - self.session_store.append_to_transcript( + session_entry = await self.async_session_store.get_or_create_session(event.source) + await self.async_session_store.append_to_transcript( session_entry.session_id, reload_msg ) except Exception: @@ -15237,11 +15508,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew user_name=str(evt.get("user_name") or "").strip() or None, ) - async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None: - """Inject a watch-pattern notification as a synthetic message event. + async def _inject_watch_notification( + self, synth_text: str, evt: dict, + ) -> Optional[bool]: + """Inject a watch/completion notification as a synthetic message event. - Routing must come from the queued watch event itself, not from whatever + Routing must come from the queued event itself, not from whatever foreground message happened to be active when the queue was drained. + Returns ``True`` after adapter acceptance, ``False`` after a retryable + adapter failure, and ``None`` when the event has no gateway route. This + is not a transactional boundary: a process crash after adapter + acceptance can still cause durable at-least-once replay. """ source = self._build_process_event_source(evt) if not source: @@ -15249,7 +15526,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "Dropping watch notification with no routing metadata for process %s", evt.get("session_id", "unknown"), ) - return + return None platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform) adapter = None for p, a in self.adapters.items(): @@ -15257,7 +15534,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew adapter = a break if not adapter: - return + return None try: metadata = {} parent_session_id = str(evt.get("parent_session_id") or "").strip() @@ -15278,8 +15555,118 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew source.thread_id, ) await adapter.handle_message(synth_event) + return True except Exception as e: logger.error("Watch notification injection error: %s", e) + return False + + @staticmethod + def _completion_delivery_identity(evt: dict) -> Optional[tuple[str, str, object]]: + """Return a producer-stable identity when one is available. + + Delegation UUIDs identify one producer completion. Process session IDs + are normally unique too, but include the persisted spawn epoch so an + explicitly reused ID represents a distinct process incarnation. Legacy + process events without ``started_at`` are delivered without deduplication + rather than risking suppression of a real completion. + """ + evt_type = str(evt.get("type") or "") + if evt_type == "async_delegation": + producer_id = str(evt.get("delegation_id") or "") + return (evt_type, producer_id, "") if producer_id else None + if evt_type == "completion": + producer_id = str(evt.get("session_id") or "") + started_at = evt.get("started_at") + if producer_id and started_at is not None: + return (evt_type, producer_id, started_at) + return None + + async def _deliver_completion_notification( + self, synth_text: str, evt: dict, + ) -> Optional[bool]: + """Deliver once per live gateway, or return False for a retry. + + ``True`` means this caller reached adapter acceptance, ``False`` means + injection failed and the claim was released for retry, and ``None`` + means either another same-lifecycle caller owns/delivered the producer + event or the event has no gateway route. No cross-process exactly-once + guarantee is claimed. + """ + identity = self._completion_delivery_identity(evt) + durable_claim_id = "" + durable_delegation_id = "" + if evt.get("type") == "async_delegation": + durable_delegation_id = str(evt.get("delegation_id") or "") + if durable_delegation_id: + try: + from tools.async_delegation import claim_completion_delivery + + durable_claim_id = f"gateway:{id(self)}:{__import__('uuid').uuid4().hex}" + if not claim_completion_delivery( + durable_delegation_id, durable_claim_id, + ): + return None + except Exception as exc: + logger.warning( + "Could not claim durable async completion %s: %s", + durable_delegation_id, exc, + ) + return False + if identity is not None: + with self._completion_delivery_lock: + if ( + identity in self._completion_deliveries_inflight + or identity in self._completion_deliveries_delivered + ): + return None + self._completion_deliveries_inflight.add(identity) + + accepted = False + try: + injection_result = await self._inject_watch_notification(synth_text, evt) + if injection_result is not True: + return injection_result + accepted = True + + if identity is not None: + with self._completion_delivery_lock: + self._completion_deliveries_inflight.discard(identity) + self._completion_deliveries_delivered[identity] = None + while ( + len(self._completion_deliveries_delivered) + > self._completion_delivery_retention + ): + self._completion_deliveries_delivered.popitem(last=False) + + # If the durable async-delegation producer branch is present, its + # SQLite row remains the authoritative replay state. Acknowledge it + # after adapter acceptance; this gateway keeps no parallel ledger. + if durable_claim_id: + try: + from tools.async_delegation import complete_completion_delivery + + complete_completion_delivery( + durable_delegation_id, durable_claim_id, + ) + except Exception as exc: + logger.warning( + "Could not acknowledge durable async completion %s: %s", + durable_delegation_id, exc, + ) + return True + finally: + if identity is not None and not accepted: + with self._completion_delivery_lock: + self._completion_deliveries_inflight.discard(identity) + if durable_claim_id and not accepted: + try: + from tools.async_delegation import release_completion_delivery + + release_completion_delivery( + durable_delegation_id, durable_claim_id, + ) + except Exception: + logger.debug("Could not release durable completion claim", exc_info=True) def _enrich_async_delegation_routing(self, evt: dict) -> None: """Fill platform/chat_id/thread_id/chat_type on an async-delegation event. @@ -15342,8 +15729,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if not synth_text: continue try: - await self._inject_watch_notification(synth_text, evt) + delivered = await self._deliver_completion_notification(synth_text, evt) + if delivered is False: + _pr.completion_queue.put(evt) except Exception as e: + _pr.completion_queue.put(evt) logger.error("Async delegation injection error: %s", e) except Exception as e: logger.debug("Async delegation watcher error: %s", e) @@ -15409,8 +15799,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # (#10156) — a status check must not suppress this delivery turn. from tools.process_registry import format_process_notification, process_registry as _pr_check if agent_notify and not _pr_check.is_completion_consumed(session_id): + from agent.redact import redact_terminal_output from tools.ansi_strip import strip_ansi + _command = getattr(session, "command", "") or "" _raw = strip_ansi(session.output_buffer) if session.output_buffer else "" + _raw = redact_terminal_output(_raw, _command) + _command = _redact_gateway_user_facing_secrets(_command) # Truncate at line boundaries so notifications never start # mid-line (fixes #23284). Keep the last ~2000 chars but # snap to the nearest preceding newline, then prepend a @@ -15423,57 +15817,34 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _out = f"[… output truncated — showing last {len(_tail)} chars]\n{_tail}" else: _out = _raw - synth_text = format_process_notification({ + completion_evt = { "type": "completion", - "session_id": session_id, - "command": session.command, - "exit_code": session.exit_code, - "completion_reason": getattr(session, "completion_reason", "exited"), - "termination_source": getattr(session, "termination_source", ""), - "output": _out, - }) - if not synth_text: - break - source = self._build_process_event_source({ "session_id": session_id, "session_key": session_key, "platform": platform_name, + "chat_type": watcher.get("chat_type", ""), "chat_id": chat_id, "thread_id": thread_id, "user_id": user_id, "user_name": user_name, - }) - if not source: - logger.warning( - "Dropping completion notification with no routing metadata for process %s", - session_id, - ) + "message_id": message_id, + "started_at": getattr(session, "started_at", None), + "command": _command, + "exit_code": session.exit_code, + "completion_reason": getattr(session, "completion_reason", "exited"), + "termination_source": getattr(session, "termination_source", ""), + "output": _out, + } + synth_text = format_process_notification(completion_evt) + if not synth_text: break - - adapter = None - for p, a in self.adapters.items(): - if p == source.platform: - adapter = a - break - if adapter and source.chat_id: - try: - synth_event = MessageEvent( - text=synth_text, - message_type=MessageType.TEXT, - source=source, - internal=True, - message_id=message_id, - ) - logger.info( - "Process %s finished — injecting agent notification for session %s chat=%s thread=%s", - session_id, - session_key, - source.chat_id, - source.thread_id, - ) - await adapter.handle_message(synth_event) - except Exception as e: - logger.error("Agent notify injection error: %s", e) + delivered = await self._deliver_completion_notification( + synth_text, completion_evt, + ) + if delivered is False: + # The process remains terminal; retry after failed + # adapter injection instead of suppressing the result. + continue break # --- Normal text-only notification --- @@ -16434,7 +16805,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if url: return url.rstrip("/") cfg = _load_gateway_config() - url = (cfg.get("gateway") or {}).get("proxy_url", "").strip() + url = (cfg.get("gateway") or {}).get("proxy_url") + url = (url or "").strip() if url: return url.rstrip("/") return None @@ -18065,6 +18437,18 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.debug("Reusing cached agent for session %s", session_key) reused_cached_agent = True + # Lock released — refresh the fallback chain from disk for the + # reused agent OUTSIDE the cache lock (config.yaml read is disk + # I/O; the idle-sweep watcher contends on this lock and stalls + # Discord heartbeats — same reasoning as #52197). A chain + # configured after this agent was cached (or after gateway start) + # must reach the next turn (#60955). Per-session turn + # serialization (_running_agents) keeps this safe post-lock. + if reused_cached_agent and agent is not None: + self._apply_fallback_chain_to_agent( + agent, self._refresh_fallback_model(), + ) + # Lock released — now schedule cleanup of any cross-process-evicted # agent on a daemon thread so memory-provider shutdown / socket # teardown never blocks the gateway event loop or the cache lock @@ -18117,7 +18501,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew thread_id=source.thread_id, gateway_session_key=session_key, session_db=getattr(self._session_db, "_db", self._session_db), - fallback_model=self._fallback_model, + # Reload from disk — do not reuse the startup snapshot (#60955). + fallback_model=self._refresh_fallback_model(), ) if _cache_lock and _cache is not None: with _cache_lock: @@ -18438,6 +18823,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew session_key=_approval_session_key, description=desc, metadata=_status_thread_metadata, + allow_permanent=approval_data.get("allow_permanent", True), + smart_denied=approval_data.get("smart_denied", False), ), _loop_for_step, logger=logger, @@ -18462,13 +18849,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # can actually type (`!approve`) — typed "/" is blocked in # Slack threads and reserved by Matrix clients. _p = getattr(_status_adapter, "typed_command_prefix", "/") - cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd - msg = ( - f"⚠️ **Dangerous command requires approval:**\n" - f"```\n{cmd_preview}\n```\n" - f"Reason: {desc}\n\n" - f"Reply `{_p}approve` to execute, `{_p}approve session` to approve this pattern " - f"for the session, `{_p}approve always` to approve permanently, or `{_p}deny` to cancel." + msg = _format_exec_approval_fallback( + cmd, + desc, + _p, + allow_permanent=approval_data.get("allow_permanent", True), + smart_denied=approval_data.get("smart_denied", False), ) try: _approval_send_fut = safe_schedule_threadsafe( @@ -19707,7 +20093,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew session_key or "?", exc_info=True, ) - next_message = await self._prepare_inbound_message_text( + next_message = await self._prepare_profile_scoped_inbound_message_text( event=pending_event, source=next_source, history=updated_history, @@ -20630,13 +21016,21 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = # historical in-process 60s ticker; an external provider (e.g. chronos) # may arm a schedule and return. Pass the event loop so cron delivery can # use live adapters (E2EE support). - from cron.scheduler_provider import resolve_cron_scheduler + from cron.scheduler_provider import InProcessCronScheduler, resolve_cron_scheduler cron_stop = threading.Event() cron_provider = resolve_cron_scheduler() + cron_start_kwargs = {"adapters": runner.adapters, "loop": asyncio.get_running_loop()} + # External cron providers own their remote scheduling contract. Only the + # in-process ticker polls local due jobs, so only it receives the local + # external-drain dispatch gate. + if isinstance(cron_provider, InProcessCronScheduler): + cron_start_kwargs["can_dispatch"] = lambda: not ( + runner._draining or runner._external_drain_active + ) cron_thread = threading.Thread( target=cron_provider.start, args=(cron_stop,), - kwargs={"adapters": runner.adapters, "loop": asyncio.get_running_loop()}, + kwargs=cron_start_kwargs, daemon=True, name="cron-scheduler", ) diff --git a/gateway/session.py b/gateway/session.py index fb2e08f4299..fea3ba4a3c0 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -8,6 +8,7 @@ Handles: - Dynamic system prompt injection (agent knows its context) """ +import asyncio import hashlib import logging import os @@ -958,6 +959,30 @@ def build_session_key( return ":".join(key_parts) +class _SessionFlight: + def __init__(self) -> None: + self.event = threading.Event() + self.result: Optional["SessionEntry"] = None + self.error: Optional[BaseException] = None + + +class AsyncSessionStore: + """Async boundary for the synchronous, thread-safe SessionStore.""" + + def __init__(self, store: "SessionStore") -> None: + self._store = store + + def __getattr__(self, name: str): + attr = getattr(self._store, name) + if not callable(attr): + return attr + + async def _offloaded(*args, **kwargs) -> Any: + return await asyncio.to_thread(attr, *args, **kwargs) + + return _offloaded + + class SessionStore: """ Manages session storage and retrieval. @@ -973,6 +998,14 @@ class SessionStore: self._entries: Dict[str, SessionEntry] = {} self._loaded = False self._lock = threading.Lock() + # Serialize whole-index persistence without holding ``_lock`` across + # SQLite / fsync. Each writer snapshots the latest state only after + # acquiring this lock, preventing stale delayed writes. + self._save_lock = threading.Lock() + self._routing_generation = 0 + self._persisted_routing_generation = 0 + self._inflight_lock = threading.Lock() + self._inflight_sessions: Dict[str, _SessionFlight] = {} self._has_active_processes_fn = has_active_processes_fn # Whether to keep writing the legacy sessions.json mirror alongside # the primary gateway_routing table in state.db. Default True for @@ -1179,40 +1212,45 @@ class SessionStore: self._save() def _save(self) -> None: - """Persist the routing index (session key -> ID mapping). + """Persist the routing index while the caller holds ``_lock``.""" + data, generation = self._snapshot_routing_locked() + self._persist_routing_data(data, generation) - state.db's ``gateway_routing`` table is the primary store (#9006 - follow-up): the whole index is replaced atomically in one SQLite - transaction, mirroring the previous full-file JSON rewrite semantics. + def _snapshot_routing_locked(self) -> tuple[Dict[str, Any], int]: + """Capture immutable routing data and a monotonic generation.""" + self._routing_generation = getattr(self, "_routing_generation", 0) + 1 + return ( + {key: entry.to_dict() for key, entry in self._entries.items()}, + self._routing_generation, + ) - sessions.json is additionally written for backward compatibility - (external tooling, downgrade safety) unless the user disables it via - ``gateway.write_sessions_json: false`` in config.yaml. - """ - data = {key: entry.to_dict() for key, entry in self._entries.items()} - - # Primary: durable SQLite routing table. - db_saved = False - _db = getattr(self, "_db", None) - if _db: - replacer = getattr(_db, "replace_gateway_routing_entries", None) - if callable(replacer): - try: - replacer( - {k: json.dumps(v) for k, v in data.items()}, - scope=self._routing_scope(), - ) - db_saved = True - except Exception as exc: - logger.warning( - "gateway.session: state.db routing save failed: %s", exc - ) - - # Legacy mirror: sessions.json. Kept on by default for compat; when - # disabled we still fall back to it if the DB write failed, so the - # index is never lost entirely. - if getattr(self, "_write_sessions_json", True) or not db_saved: - self._save_sessions_json(data) + def _persist_routing_data(self, data: Dict[str, Any], generation: int) -> None: + """Serialize all whole-index writers through one durable write lock.""" + save_lock = getattr(self, "_save_lock", None) + if save_lock is None: + save_lock = threading.Lock() + self._save_lock = save_lock + with save_lock: + if generation <= getattr(self, "_persisted_routing_generation", 0): + return + db_saved = False + _db = getattr(self, "_db", None) + if _db: + replacer = getattr(_db, "replace_gateway_routing_entries", None) + if callable(replacer): + try: + replacer( + {k: json.dumps(v) for k, v in data.items()}, + scope=self._routing_scope(), + ) + db_saved = True + except Exception as exc: + logger.warning( + "gateway.session: state.db routing save failed: %s", exc + ) + if getattr(self, "_write_sessions_json", True) or not db_saved: + self._save_sessions_json(data) + self._persisted_routing_generation = generation def _save_sessions_json(self, data: Dict[str, Any]) -> None: """Write the legacy sessions.json mirror of the routing index.""" @@ -1254,6 +1292,11 @@ class SessionStore: logger.debug("Could not remove temp file %s: %s", tmp_path, e) raise + def _save_entries(self) -> None: + """Snapshot latest state under ``_lock`` and persist after releasing it.""" + with self._lock: + data, generation = self._snapshot_routing_locked() + self._persist_routing_data(data, generation) def _resolve_profile_for_key(self, source: Optional[SessionSource] = None) -> Optional[str]: """Return the profile namespace for session keys, or None when off. @@ -1395,6 +1438,51 @@ class SessionStore: now=now, ) + def _query_recoverable_session(self, *, session_key, source, now): + """DB-only half of _recover_session_from_db (no lock needed). + + Returns a SessionEntry or None. Caller assigns _entries[key] under lock. + """ + if not self._db: + return None + finder = getattr(self._db, "find_latest_gateway_session_for_peer", None) + if not callable(finder): + return None + try: + recovered = finder( + source=source.platform.value, + user_id=source.user_id, + session_key=session_key, + chat_id=source.chat_id, + chat_type=source.chat_type, + thread_id=source.thread_id, + ) + except Exception as exc: + logger.debug("Gateway session DB recovery failed for %s: %s", + session_key, exc) + return None + if not isinstance(recovered, dict): + return None + if not self._recovered_row_allowed_for_active_profile( + requested_session_key=session_key, + recovered=recovered, + ): + logger.warning( + "Gateway session DB recovery ignored %s for %s because " + "multiplex_profiles is disabled and the row belongs to a " + "different profile", + recovered.get("session_key"), + session_key, + ) + return None + try: + self._db.reopen_session(str(recovered["id"])) + except Exception as exc: + logger.debug("Gateway session DB reopen failed for %s: %s", + session_key, exc) + return self._create_entry_from_recovered_row( + row=recovered, session_key=session_key, source=source, now=now, + ) def _record_gateway_session_peer( self, session_id: str, @@ -1687,23 +1775,69 @@ class SessionStore: def get_or_create_session( self, source: SessionSource, - force_new: bool = False + force_new: bool = False, ) -> SessionEntry: - """ - Get an existing session or create a new one. + """Single-flight session lookup/create per routing key. - Evaluates reset policy to determine if the existing session is stale. - Creates a session record in SQLite when a new session starts. + Calls for different keys remain concurrent. Overlapping calls for the + same key share the owner's result, including concurrent ``force_new`` + deliveries, so only one routing transition and SQLite row is created. + """ + session_key = self._generate_session_key(source) + inflight_lock = getattr(self, "_inflight_lock", None) + if inflight_lock is None: + inflight_lock = threading.Lock() + self._inflight_lock = inflight_lock + self._inflight_sessions = {} + + with inflight_lock: + slot = self._inflight_sessions.get(session_key) + if slot is None: + slot = _SessionFlight() + self._inflight_sessions[session_key] = slot + owner = True + else: + owner = False + + if not owner: + slot.event.wait() + if slot.error is not None: + raise slot.error + assert slot.result is not None + return slot.result + + try: + result = self._get_or_create_session_impl(source, force_new=force_new) + slot.result = result + return result + except BaseException as exc: + slot.error = exc + raise + finally: + slot.event.set() + with inflight_lock: + self._inflight_sessions.pop(session_key, None) + + def _get_or_create_session_impl( + self, + source: SessionSource, + force_new: bool = False, + ) -> SessionEntry: + """Perform one session routing transition for the single-flight owner. + + All blocking I/O (SQLite SELECTs, routing-index rewrite + ``os.fsync``, + recovery DB queries) is performed *outside* ``self._lock``. The lock + protects only ``_entries`` / ``_loaded`` mutations. """ session_key = self._generate_session_key(source) now = _now() - # SQLite calls are made outside the lock to avoid holding it during I/O. - # All _entries / _loaded mutations are protected by self._lock. db_end_session_id = None db_create_kwargs = None existing_session_id = None + force_new_observed_entry = None + # ---- Phase 0: lock read -- existing session_id for compression tip ---- if not force_new: with self._lock: self._ensure_loaded_locked() @@ -1711,13 +1845,52 @@ class SessionStore: if entry is not None: existing_session_id = entry.session_id - # Look up the compression continuation outside the lock (DB I/O). + # Compression tip lookup outside the lock (DB I/O). canonical_existing_session_id = ( self._compression_tip_for_session_id(existing_session_id) if existing_session_id else None ) + # ---- Phase 1: lock read -- get entry snapshot for stale/reset checks ---- + _stale_session_id = None + _entry_for_checks = None + with self._lock: + self._ensure_loaded_locked() + if force_new: + force_new_observed_entry = self._entries.get(session_key) + if session_key in self._entries and not force_new: + _entry_for_checks = self._entries[session_key] + _stale_session_id = _entry_for_checks.session_id + + # ---- Phase 1b: no-lock I/O -- stale check + reset policy ---- + _is_stale = False + _reset_reason = None + if _entry_for_checks is not None and _stale_session_id is not None: + _is_stale = self._is_session_ended_in_db(_stale_session_id) + if _entry_for_checks.suspended: + _reset_reason = "suspended" + elif _entry_for_checks.resume_pending: + _reset_reason = self._should_reset(_entry_for_checks, source) + if not _reset_reason: + _fw = auto_continue_freshness_window() + _ref_time = ( + _entry_for_checks.last_resume_marked_at + or _entry_for_checks.updated_at + ) + if _fw > 0 and (now - _ref_time).total_seconds() > _fw: + _reset_reason = "resume_pending_expired" + else: + _reset_reason = self._should_reset(_entry_for_checks, source) + + # ---- Phase 2: lock write -- apply decisions to _entries ---- + _needs_save = False + _needs_recover = False + entry: Optional[SessionEntry] = None + was_auto_reset = False + auto_reset_reason = None + reset_had_activity = False + with self._lock: self._ensure_loaded_locked() @@ -1727,25 +1900,13 @@ class SessionStore: entry, existing_session_id, canonical_existing_session_id ) - # Self-heal stale routing: if this session_key still points at - # a session that has ALREADY been ended in state.db (end_reason - # set), the in-memory sessions.json entry is stale. Reusing it - # would route every incoming message into a closed session and - # silently drop it — with no log, no error, no response — until - # the gateway restarts and _prune_stale_sessions_locked() clears - # it (#54878 — the live-gateway variant of #52804/FM9, which - # only the startup prune previously caught). - # - # Drop the stale entry and fall through to the recovery path - # below. Leaving db_end_session_id None routes us into - # _recover_session_from_db, whose finder - # (hermes_state.find_latest_gateway_session_for_peer) selects - # rows WHERE `ended_at IS NULL OR end_reason = 'agent_close'` - # — so it REOPENS gateway-cleanup-ended ('agent_close') rows and - # resumes the SAME session_id (transcript preserved), but returns - # None for any other end_reason (e.g. /new), which then correctly - # starts a fresh session. - if self._is_session_ended_in_db(entry.session_id): + if _is_stale and entry.session_id == _stale_session_id: + # Stale routing self-heal (#54878): the in-memory entry + # points at a session that has ALREADY been ended in + # state.db. Drop it and fall through to recovery/create. + # Recovery finder reopens ``agent_close`` rows (preserving + # the transcript) but returns None for other end_reasons + # (e.g. /new), starting a fresh session. logger.warning( "gateway.session: routing key %r -> %s is ended in " "state.db but still live in sessions.json; dropping " @@ -1754,82 +1915,49 @@ class SessionStore: session_key, entry.session_id, ) self._entries.pop(session_key, None) - was_auto_reset = False - auto_reset_reason = None - reset_had_activity = False - # Fall through to the recovery/create path below; the - # stale entry is gone so we must NOT consult its - # suspended/resume/reset state. + entry = None + _needs_recover = True + elif entry.session_id != _stale_session_id: + # Another thread handled this entry during our lock-free + # window. Treat as healthy -- bump updated_at and save. + entry.updated_at = now + _needs_save = True else: - # Auto-reset sessions marked as suspended (e.g. after /stop - # broke a stuck loop — #7536). ``suspended`` is the hard - # forced-wipe signal and always wins over ``resume_pending``, - # so repeated interrupted restarts that escalate via the - # existing ``.restart_failure_counts`` stuck-loop counter - # still converge to a clean slate. - if entry.suspended: - reset_reason = "suspended" - elif entry.resume_pending: - # Restart-interrupted session: preserve the session_id - # and return the existing entry so the transcript reloads - # intact, but still honour normal daily/idle reset policy. - # - # Freshness gate (#46934): the idle/daily policy checks - # ``updated_at``, which is bumped to ``now`` on every - # message — so a zombie session that keeps receiving - # messages never trips it and would resume stale context - # forever. ``last_resume_marked_at`` is set once when - # resume was marked and never bumped per-message, so it - # correctly measures how long resume has been pending. - # If that exceeds the auto-continue freshness window, the - # recovery turn either never ran or failed — treat the - # session as a zombie and fall through to auto-reset. - reset_reason = self._should_reset(entry, source) - if not reset_reason: - _fw = auto_continue_freshness_window() - _ref_time = entry.last_resume_marked_at or entry.updated_at - if _fw > 0 and (now - _ref_time).total_seconds() > _fw: - reset_reason = "resume_pending_expired" - else: - entry.updated_at = now - self._save() - return entry - else: - reset_reason = self._should_reset(entry, source) - if not reset_reason: - entry.updated_at = now - self._save() - return entry - else: - # Session is being auto-reset. + # Stale check clean. Apply reset decision. + if _reset_reason: was_auto_reset = True - auto_reset_reason = reset_reason - # Track whether the expired session had any real - # conversation. total_tokens is never written (token - # counts migrated to agent-direct persistence) so it is - # always 0 — use last_prompt_tokens, updated every turn. + auto_reset_reason = _reset_reason reset_had_activity = entry.last_prompt_tokens > 0 db_end_session_id = entry.session_id + self._entries.pop(session_key, None) + entry = None + _needs_recover = True + else: + entry.updated_at = now + _needs_save = True else: - was_auto_reset = False - auto_reset_reason = None - reset_had_activity = False + if not force_new: + _needs_recover = True - if not force_new and not db_end_session_id: - recovered_entry = self._recover_session_from_db( - session_key=session_key, - source=source, - now=now, - ) - if recovered_entry is not None: - self._entries[session_key] = recovered_entry - self._save() - return recovered_entry + # ---- Phase 3: no-lock I/O -- recovery + create + save + DB ops ---- + if _needs_recover and db_end_session_id is None: + recovered = self._query_recoverable_session( + session_key=session_key, source=source, now=now, + ) + if recovered is not None: + with self._lock: + published = self._entries.get(session_key) + if published is None: + self._entries[session_key] = recovered + published = recovered + entry = published + _needs_save = True - # Create new session + if entry is None: + # Create a candidate outside the lock, then publish only if another + # worker has not already populated this routing key. session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" - - entry = SessionEntry( + candidate = SessionEntry( session_key=session_key, session_id=session_id, created_at=now, @@ -1842,20 +1970,34 @@ class SessionStore: auto_reset_reason=auto_reset_reason, reset_had_activity=reset_had_activity, ) + with self._lock: + current = self._entries.get(session_key) + may_publish = current is None or ( + force_new and current is force_new_observed_entry + ) + if may_publish: + self._entries[session_key] = candidate + published = candidate + else: + published = current + assert published is not None + entry = published + _needs_save = True + if entry is candidate: + db_create_kwargs = { + "session_id": session_id, + "source": source.platform.value, + "user_id": source.user_id, + "session_key": session_key, + "chat_id": source.chat_id, + "chat_type": source.chat_type, + "thread_id": source.thread_id, + } - self._entries[session_key] = entry - self._save() - db_create_kwargs = { - "session_id": session_id, - "source": source.platform.value, - "user_id": source.user_id, - "session_key": session_key, - "chat_id": source.chat_id, - "chat_type": source.chat_type, - "thread_id": source.thread_id, - } + if _needs_save: + self._save_entries() - # SQLite operations outside the lock + # SQLite operations outside the lock (unchanged). if self._db and db_end_session_id: try: self._db.end_session(db_end_session_id, "session_reset") diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 3d7bed924c7..38ab051c8e4 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -34,6 +34,7 @@ from agent.i18n import t from gateway.config import HomeChannel, Platform, PlatformConfig from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType from gateway.session import ( + AsyncSessionStore, SessionSource, build_session_key, is_shared_multi_user_session, @@ -86,6 +87,8 @@ def _model_switch_skew_guard() -> Optional[str]: class GatewaySlashCommandsMixin: """In-session slash-command handlers for GatewayRunner.""" + async_session_store: AsyncSessionStore + def _typed_command_prefix_for(self, platform) -> str: """Return the prefix users can always type to reach Hermes commands. @@ -198,7 +201,7 @@ class GatewaySlashCommandsMixin: pass # Reset the session - new_entry = self.session_store.reset_session(session_key) + new_entry = await self.async_session_store.reset_session(session_key) # Clear any session-scoped model/reasoning overrides so the next agent # picks up configured defaults instead of previous session switches. @@ -263,7 +266,7 @@ class GatewaySlashCommandsMixin: header = await asyncio.to_thread(self._telegram_topic_new_header, source) or t("gateway.reset.header_default") else: # No existing session, just create one - new_entry = self.session_store.get_or_create_session(source, force_new=True) + new_entry = await self.async_session_store.get_or_create_session(source, force_new=True) header = await asyncio.to_thread(self._telegram_topic_new_header, source) or t("gateway.reset.header_new") # Set session title if provided with /new <title> @@ -495,7 +498,7 @@ class GatewaySlashCommandsMixin: from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model source = event.source - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) connected_platforms = [p.value for p in self.adapters.keys()] @@ -1061,7 +1064,7 @@ class GatewaySlashCommandsMixin: """ from gateway.run import _AGENT_PENDING_SENTINEL, _INTERRUPT_REASON_STOP source = event.source - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) session_key = session_entry.session_key agent = self._running_agents.get(session_key) @@ -1598,7 +1601,7 @@ class GatewaySlashCommandsMixin: _sess_db = getattr(_self, "_session_db", None) if _sess_db is not None: try: - _sess_entry = _self.session_store.get_or_create_session( + _sess_entry = await _self.async_session_store.get_or_create_session( event.source ) await _sess_db.update_session_model( @@ -1629,7 +1632,7 @@ class GatewaySlashCommandsMixin: # store so the picked model survives a gateway restart # (api_key is never persisted). try: - _self.session_store.set_model_override( + await _self.async_session_store.set_model_override( _session_key, _self._session_model_overrides[_session_key], ) @@ -1840,7 +1843,7 @@ class GatewaySlashCommandsMixin: _sess_db = getattr(self, "_session_db", None) if _sess_db is not None: try: - _sess_entry = self.session_store.get_or_create_session(source) + _sess_entry = await self.async_session_store.get_or_create_session(source) # If this session was auto-reset, consume the flag so the # next regular message's cleanup does not wipe the model # override just stored below (Closes #48031). @@ -1878,8 +1881,9 @@ class GatewaySlashCommandsMixin: # api_key/api_mode are never persisted — they are re-resolved via # runtime provider resolution on rehydration. try: - self.session_store.set_model_override( - session_key, self._session_model_overrides[session_key] + await self.async_session_store.set_model_override( + session_key, + self._session_model_overrides[session_key], ) except Exception: logger.debug( @@ -2142,8 +2146,8 @@ class GatewaySlashCommandsMixin: async def _handle_retry_command(self, event: MessageEvent) -> str: """Handle /retry command - re-send the last user message.""" source = event.source - session_entry = self.session_store.get_or_create_session(source) - history = self.session_store.load_transcript(session_entry.session_id) + session_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(session_entry.session_id) # Find the last user message last_user_msg = None @@ -2159,10 +2163,10 @@ class GatewaySlashCommandsMixin: # Truncate history to before the last user message and persist truncated = history[:last_user_idx] - self.session_store.rewrite_transcript(session_entry.session_id, truncated) + await self.async_session_store.rewrite_transcript(session_entry.session_id, truncated) # Reset stored token count — transcript was truncated session_entry.last_prompt_tokens = 0 - + # Re-send by creating a fake text event with the old message retry_event = MessageEvent( text=last_user_msg, @@ -2189,7 +2193,7 @@ class GatewaySlashCommandsMixin: args = (event.get_command_args() or "").strip() lower = args.lower() - mgr, session_entry = self._get_goal_manager_for_event(event) + mgr, session_entry = await self._get_goal_manager_for_event(event) if mgr is None: return t("gateway.goal.unavailable") @@ -2323,7 +2327,7 @@ class GatewaySlashCommandsMixin: to invoke while the agent is running. """ args = (event.get_command_args() or "").strip() - mgr, _session_entry = self._get_goal_manager_for_event(event) + mgr, _session_entry = await self._get_goal_manager_for_event(event) if mgr is None: return t("gateway.goal.unavailable") if not mgr.has_goal(): @@ -2390,8 +2394,8 @@ class GatewaySlashCommandsMixin: if n < 1: n = 1 - session_entry = self.session_store.get_or_create_session(source) - result = self.session_store.rewind_session(session_entry.session_id, n) + session_entry = await self.async_session_store.get_or_create_session(source) + result = await self.async_session_store.rewind_session(session_entry.session_id, n) if result is None: return t("gateway.undo.nothing") @@ -2729,7 +2733,7 @@ class GatewaySlashCommandsMixin: return t("gateway.reasoning.reset_done") if effort == "none": parsed = {"enabled": False} - elif effort in {"minimal", "low", "medium", "high", "xhigh"}: + elif effort in {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"}: parsed = {"enabled": True, "effort": effort} else: return t( @@ -3090,8 +3094,8 @@ class GatewaySlashCommandsMixin: https://code.claude.com/docs/en/whats-new/2026-w20). """ source = event.source - session_entry = self.session_store.get_or_create_session(source) - history = self.session_store.load_transcript(session_entry.session_id) + session_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(session_entry.session_id) if not history or len(history) < 4: return t("gateway.compress.not_enough") @@ -3262,32 +3266,40 @@ class GatewaySlashCommandsMixin: # at it; in place the original transcript is untouched) and lets # the outer handler surface a "compress failed" banner instead. # - # The rewrite runs when EITHER rotation produced a new id OR - # in-place compaction succeeded. It is skipped in the THIRD - # case: _compress_context could NOT rotate AND was not in-place - # (e.g. legacy mode but _session_db unavailable / the DB split - # raised) — there session_id is unchanged for a FAILURE reason, - # and rewrite_transcript() would DELETE the original messages and - # replace them with only the compressed summary (permanent data - # loss #44794, #39704). In in-place mode the unchanged id is - # SUCCESS, so the rewrite is exactly right (and is the durable - # write when the throwaway /compress agent has no _session_db of - # its own). - if rotated or _in_place: - if not self.session_store.rewrite_transcript( + # Only rewrite the transcript when rotation produced a NEW + # session id. In-place compaction does NOT need a rewrite: + # archive_and_compact() has already soft-archived the previous + # active rows and inserted the compacted messages as the new + # active set inside _compress_context(). Calling + # rewrite_transcript() after in-place compaction would invoke + # replace_messages(active_only=False) which DELETEs ALL rows — + # including the archived turns that archive_and_compact() + # deliberately preserved (silent data loss, #61145). + # + # The third case: _compress_context could NOT rotate AND was + # not in-place (e.g. legacy mode but _session_db unavailable / + # the DB split raised) — there session_id is unchanged for a + # FAILURE reason, and rewrite_transcript() would DELETE the + # original messages and replace them with only the compressed + # summary (permanent data loss #44794, #39704). + if rotated: + if not await self.async_session_store.rewrite_transcript( new_session_id, compressed ): raise RuntimeError( f"failed to persist compressed transcript for " f"session {new_session_id}" ) - if rotated: - session_entry.session_id = new_session_id - self.session_store._save() - await asyncio.to_thread( - self._sync_telegram_topic_binding, - source, session_entry, reason="compress-command", - ) + session_entry.session_id = new_session_id + await self.async_session_store._save() + await asyncio.to_thread( + self._sync_telegram_topic_binding, + source, session_entry, reason="compress-command", + ) + elif _in_place: + # archive_and_compact() already persisted the compacted + # transcript inside _compress_context — nothing to do. + pass else: logger.warning( "Manual /compress: session rotation did not occur " @@ -3296,7 +3308,7 @@ class GatewaySlashCommandsMixin: "it (#44794)." ) # Reset stored token count — transcript changed, old value is stale - self.session_store.update_session( + await self.async_session_store.update_session( session_entry.session_key, last_prompt_tokens=0 ) new_tokens = estimate_request_tokens_rough( @@ -3445,7 +3457,7 @@ class GatewaySlashCommandsMixin: async def _handle_title_command(self, event: MessageEvent) -> str: """Handle /title command — set or show the current session's title.""" source = event.source - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) session_id = session_entry.session_id if not self._session_db: @@ -3628,7 +3640,7 @@ class GatewaySlashCommandsMixin: return t("gateway.resume.blocked_not_owner", name=name) # Check if already on that session - current_entry = self.session_store.get_or_create_session(source) + current_entry = await self.async_session_store.get_or_create_session(source) if current_entry.session_id == target_id: return t("gateway.resume.already_on", name=name) @@ -3636,7 +3648,7 @@ class GatewaySlashCommandsMixin: self._release_running_agent_state(session_key) # Switch the session entry to point at the old session - new_entry = self.session_store.switch_session(session_key, target_id) + new_entry = await self.async_session_store.switch_session(session_key, target_id) if not new_entry: return t("gateway.resume.switch_failed") self._clear_session_boundary_security_state(session_key) @@ -3673,7 +3685,7 @@ class GatewaySlashCommandsMixin: title = await self._session_db.get_session_title(target_id) or name # Count messages for context - history = self.session_store.load_transcript(target_id) + history = await self.async_session_store.load_transcript(target_id) msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0 msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else "" @@ -3724,7 +3736,7 @@ class GatewaySlashCommandsMixin: # `/sessions all` and enumerate other origins' session ids / titles / # previews / sources — the enumeration half of the /resume IDOR. cross_origin = include_all and self._resume_caller_is_admin(source) - current_entry = self.session_store.get_or_create_session(source) + current_entry = await self.async_session_store.get_or_create_session(source) rows = await asyncio.to_thread( query_session_listing, getattr(self._session_db, "_db", self._session_db), @@ -3773,8 +3785,8 @@ class GatewaySlashCommandsMixin: session_key = self._session_key_for_source(source) # Load the current session and its transcript - current_entry = self.session_store.get_or_create_session(source) - history = self.session_store.load_transcript(current_entry.session_id) + current_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(current_entry.session_id) if not history: return t("gateway.branch.no_conversation") @@ -3841,7 +3853,7 @@ class GatewaySlashCommandsMixin: pass # Switch the session store entry to the new session - new_entry = self.session_store.switch_session(session_key, new_session_id) + new_entry = await self.async_session_store.switch_session(session_key, new_session_id) if not new_entry: return t("gateway.branch.switch_failed") self._clear_session_boundary_security_state(session_key) @@ -3959,7 +3971,7 @@ class GatewaySlashCommandsMixin: api_key = getattr(agent, "api_key", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None if not provider and getattr(self, "_session_db", None) is not None: try: - _entry_for_billing = self.session_store.get_or_create_session(source) + _entry_for_billing = await self.async_session_store.get_or_create_session(source) persisted = await self._session_db.get_session(_entry_for_billing.session_id) or {} except Exception: persisted = {} @@ -4032,7 +4044,9 @@ class GatewaySlashCommandsMixin: # Same engine the desktop popover uses (PR #54907). The system # prompt / tools / skills / memory slices read off the live agent; # the conversation slice is estimated from the session transcript. - breakdown_lines = self._context_breakdown_lines(agent, source) + breakdown_lines = await asyncio.to_thread( + self._context_breakdown_lines, agent, source + ) if breakdown_lines: lines.append("") lines.extend(breakdown_lines) @@ -4047,8 +4061,8 @@ class GatewaySlashCommandsMixin: return "\n".join(lines) # No agent at all -- check session history for a rough count - session_entry = self.session_store.get_or_create_session(source) - history = self.session_store.load_transcript(session_entry.session_id) + session_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(session_entry.session_id) if history: from agent.model_metadata import estimate_messages_tokens_rough msgs = [m for m in history if m.get("role") in {"user", "assistant"} and m.get("content")] diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index a08e169f2f9..fca8bf43847 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -160,6 +160,10 @@ class GatewayStreamConsumer: # reply that was split across the platform's edit limit while streaming # doesn't leave stale fragments above the final message. self._preview_message_ids: "set[str]" = set() + # IDs from only the active text segment. A tool boundary preserves + # the run-wide set for fresh-final bookkeeping, but a failure recovery + # must never delete an earlier finalized preamble/commentary message. + self._segment_preview_message_ids: "set[str]" = set() self._already_sent = False self._edit_supported = True # Disabled when progressive edits are no longer usable self._last_edit_time = 0.0 @@ -173,6 +177,10 @@ class GatewayStreamConsumer: # Telegram overflow delivery. In that case the already-visible prefix # is intentional content, not a stale preview to delete. self._fallback_preserve_partial_messages = False + # Keep fallback recovery responsive. Telegram's adapter already bounds + # edit retries at five seconds; a final-delivery fallback must not hold + # the stream task through a longer flood cooldown before retrying. + self._max_fallback_flood_retry_seconds = 5.0 self._flood_strikes = 0 # Consecutive flood-control edit failures self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff self._final_response_sent = False @@ -341,6 +349,7 @@ class GatewayStreamConsumer: self._fallback_final_send = False self._fallback_prefix = "" self._fallback_preserve_partial_messages = False + self._segment_preview_message_ids = set() # #29346: a tool/segment boundary means what we delivered was an interim # preamble, not the final answer — clear the flags so a premature setter # can't fool the gateway. Safe: got_done returns before any reset, and @@ -982,6 +991,36 @@ class GatewayStreamConsumer: continuation = self._continuation_text(final_text) self._fallback_final_send = False if not continuation.strip(): + # Some platforms treat a successful streaming preview as durable + # delivery. Telegram clients can instead lose or retain only part + # of that preview after a failed final edit, so opt-in adapters + # commit the completed answer with a fresh final send. + if ( + final_text.strip() + and final_text == self._visible_prefix() + and getattr( + self.adapter, + "RESEND_FINAL_ON_EMPTY_STREAM_FALLBACK", + False, + ) is True + ): + delivery = await self._send_empty_fallback_final(final_text) + if delivery == "delivered": + return + self._already_sent = True + self._fallback_prefix = "" + self._fallback_preserve_partial_messages = False + if delivery == "ambiguous": + # A timeout may mean Telegram accepted the send but the + # client never received the response. Preserve duplicate + # suppression for that one uncertain outcome. + self._final_content_delivered = True + else: + # A confirmed failure leaves the gateway free to perform + # its normal final send. + self._final_response_sent = False + self._final_content_delivered = False + return # Nothing new to send — the visible partial already matches final text. # BUT: if final_text itself has meaningful content (e.g. a timeout # message after a long tool call), the prefix-based continuation @@ -1043,13 +1082,15 @@ class GatewayStreamConsumer: ) if result.success: break - if attempt == 0 and self._is_flood_error(result): + retry_delay = self._fallback_flood_retry_delay(result) + if attempt == 0 and retry_delay is not None: logger.debug( - "Flood control on fallback send, retrying in 3s" + "Flood control on fallback send, retrying in %.1fs", + retry_delay, ) - await asyncio.sleep(3.0) + await asyncio.sleep(retry_delay) else: - break # non-flood error or second attempt failed + break # non-flood error, long flood wait, or second failure if not result or not result.success: if sent_any_chunk: @@ -1112,6 +1153,103 @@ class GatewayStreamConsumer: self._fallback_prefix = "" self._fallback_preserve_partial_messages = False + async def _send_empty_fallback_final(self, final_text: str) -> str: + """Commit a completed answer after Telegram finalization fails. + + Returns ``delivered`` on confirmed success, ``failed`` when the + gateway can safely retry, and ``ambiguous`` when a timeout may have + reached the platform already. + """ + # Tool/segment boundaries intentionally preserve the run-wide preview + # IDs for normal fresh-final cleanup. This recovery replaces only the + # active final segment, so never delete an earlier finalized preamble. + stale_ids = set(self._segment_preview_message_ids) + if self._message_id and self._message_id != "__no_edit__": + stale_ids.add(str(self._message_id)) + + result = None + for attempt in range(2): + try: + result = await self.adapter.send( + chat_id=self.chat_id, + content=final_text, + metadata=self._metadata_for_send(final=True), + ) + except Exception as exc: + logger.debug("Empty fallback final send failed: %s", exc) + return ( + "ambiguous" + if self._send_failure_may_have_delivered(exc) + else "failed" + ) + + if getattr(result, "success", False): + break + retry_delay = self._fallback_flood_retry_delay(result) + if attempt == 0 and retry_delay is not None: + logger.debug( + "Flood control on empty fallback final send; retrying in %.1fs", + retry_delay, + ) + await asyncio.sleep(retry_delay) + continue + return ( + "ambiguous" + if self._send_failure_may_have_delivered(result) + else "failed" + ) + + new_message_id = getattr(result, "message_id", None) + delete_fn = getattr(self.adapter, "delete_message", None) + if delete_fn is not None: + for stale_id in stale_ids: + if not stale_id or stale_id == new_message_id: + continue + try: + await delete_fn(self.chat_id, stale_id) + except Exception as exc: + logger.debug( + "Empty fallback preview cleanup failed (%s): %s", + stale_id, + exc, + ) + + self._segment_preview_message_ids = set() + self._message_id = new_message_id or "__no_edit__" + self._already_sent = True + self._final_response_sent = True + self._final_content_delivered = True + self._last_sent_text = final_text + self._fallback_prefix = "" + self._fallback_preserve_partial_messages = False + self._notify_new_message() + return "delivered" + + @staticmethod + def _send_failure_may_have_delivered(result_or_exc: Any) -> bool: + """Return True for timeout failures where retrying may duplicate.""" + if getattr(result_or_exc, "retryable", None) is True: + return False + error = str(getattr(result_or_exc, "error", None) or result_or_exc).lower() + name = result_or_exc.__class__.__name__.lower() + return "timeout" in error or "timed out" in error or "timeout" in name + + def _fallback_flood_retry_delay(self, result: Any) -> float | None: + """Return a bounded retry delay for a fallback send, if safe to retry.""" + if not self._is_flood_error(result): + return None + try: + delay = float(getattr(result, "retry_after", None) or 3.0) + except (TypeError, ValueError): + delay = 3.0 + if delay > self._max_fallback_flood_retry_seconds: + logger.debug( + "Flood control requests %.1fs; leaving final delivery to the gateway", + delay, + ) + return None + return max(0.0, delay) + def _is_flood_error(self, result) -> bool: """Check if a SendResult failure is due to flood control / rate limiting.""" err = getattr(result, "error", "") or "" @@ -1246,11 +1384,12 @@ class GatewayStreamConsumer: if not prefix or not prefix.strip(): return try: - await self._edit_message( + result = await self._edit_message( message_id=self._message_id, content=prefix, ) - self._last_sent_text = prefix + if getattr(result, "success", False): + self._last_sent_text = prefix except Exception: pass # best-effort — don't let this block the fallback path @@ -1330,9 +1469,11 @@ class GatewayStreamConsumer: return base def _track_preview_id(self, message_id: Optional[str]) -> None: - """Record a real preview message id for fresh-final cleanup.""" + """Record a real preview message id for finalization cleanup.""" if message_id and message_id != "__no_edit__": - self._preview_message_ids.add(str(message_id)) + message_id = str(message_id) + self._preview_message_ids.add(message_id) + self._segment_preview_message_ids.add(message_id) def _track_preview_ids_from_result(self, result: Any) -> None: """Record every message id a send/edit result exposes: the primary id @@ -1665,6 +1806,7 @@ class GatewayStreamConsumer: self._flood_strikes = 0 return True else: + immediate_final_fallback = False if ( finalize and is_turn_final @@ -1726,13 +1868,31 @@ class GatewayStreamConsumer: self._MAX_FLOOD_STRIKES, self._current_edit_interval, ) - if self._flood_strikes < self._MAX_FLOOD_STRIKES: + immediate_final_fallback = ( + finalize + and is_turn_final + and getattr( + self.adapter, + "FALLBACK_ON_FINAL_EDIT_FLOOD", + False, + ) is True + ) + if ( + self._flood_strikes < self._MAX_FLOOD_STRIKES + and not immediate_final_fallback + ): # Don't disable edits yet — just slow down. # Update _last_edit_time so the next edit # respects the new interval. self._last_edit_time = time.monotonic() return False + if immediate_final_fallback: + logger.debug( + "Turn-final edit hit flood control; " + "entering fallback immediately" + ) + # Non-flood error OR flood strikes exhausted: enter # fallback mode — send only the missing tail once the # final response is available. @@ -1745,8 +1905,12 @@ class GatewayStreamConsumer: self._edit_supported = False self._already_sent = True # Best-effort: strip the cursor from the last visible - # message so the user doesn't see a stuck ▉. - await self._try_strip_cursor() + # message so the user doesn't see a stuck ▉. A + # turn-final Telegram flood skips this cosmetic edit: + # another edit would consume the same flood budget and + # delay the fallback send that carries the answer. + if not immediate_final_fallback: + await self._try_strip_cursor() return False else: # Editing not supported — skip intermediate updates. diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index b2be73871b2..a4027221de4 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -160,6 +160,12 @@ def build_top_level_parser(): default=None, help="Resume a previous session by ID or title", ) + parser.add_argument( + "--no-restore-cwd", + action="store_true", + default=False, + help="Don't cd into a resumed session's recorded working directory.", + ) parser.add_argument( "--continue", "-c", @@ -271,12 +277,27 @@ def build_top_level_parser(): chat_parser.add_argument( "--image", help="Optional local image path to attach to a single query" ) + # `default=argparse.SUPPRESS` on flags that are ALSO declared on the + # top-level parser: when the user writes `hermes -m foo chat`, argparse + # first sets `args.model = "foo"` from the top-level parser, then + # dispatches to the chat subparser. Without SUPPRESS the chat subparser's + # own default (`None`) would silently clobber the top-level value because + # the subparser shares the same namespace and `dest`. SUPPRESS keeps the + # subparser action a no-op unless the user actually passes the flag after + # the subcommand. Matches the pattern already used for `-s/--skills` and + # the relaunch-inherited flags `-r/--resume`, `-c/--continue`, + # `-w/--worktree`, `--yolo`, etc. (see tests/hermes_cli/ + # test_argparse_flag_propagation.py). _inherited_flag( chat_parser, - "-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)", + "-m", "--model", + default=argparse.SUPPRESS, + help="Model to use (e.g., anthropic/claude-sonnet-4)", ) chat_parser.add_argument( - "-t", "--toolsets", help="Comma-separated toolsets to enable" + "-t", "--toolsets", + default=argparse.SUPPRESS, + help="Comma-separated toolsets to enable", ) _inherited_flag( chat_parser, @@ -293,7 +314,7 @@ def build_top_level_parser(): # are also valid values, and runtime resolution (resolve_runtime_provider) # handles validation/error reporting consistently with the top-level # `--provider` flag. - default=None, + default=argparse.SUPPRESS, help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.", ) chat_parser.add_argument( @@ -316,6 +337,12 @@ def build_top_level_parser(): default=argparse.SUPPRESS, help="Resume a previous session by ID (shown on exit)", ) + chat_parser.add_argument( + "--no-restore-cwd", + action="store_true", + default=argparse.SUPPRESS, + help="Don't cd into a resumed session's recorded working directory.", + ) chat_parser.add_argument( "--continue", "-c", @@ -401,14 +428,14 @@ def build_top_level_parser(): chat_parser, "--tui", action="store_true", - default=False, + default=argparse.SUPPRESS, help="Launch the modern TUI instead of the classic REPL", ) _inherited_flag( chat_parser, "--cli", action="store_true", - default=False, + default=argparse.SUPPRESS, help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)", ) _inherited_flag( @@ -416,7 +443,7 @@ def build_top_level_parser(): "--dev", dest="tui_dev", action="store_true", - default=False, + default=argparse.SUPPRESS, help="With --tui: run TypeScript sources via tsx (skip dist build)", ) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index b203cddcc3a..65122f7d8b5 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1332,6 +1332,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. @@ -5595,52 +5616,72 @@ def resolve_nous_runtime_credentials( persisted_state = dict(state) state_persisted = False - portal_base_url = ( - _optional_base_url(state.get("portal_base_url")) - or os.getenv("HERMES_PORTAL_BASE_URL") - or os.getenv("NOUS_PORTAL_BASE_URL") - or DEFAULT_NOUS_PORTAL_URL - ).rstrip("/") + def _resolve_effective_routing_metadata() -> tuple[str, str, str, str]: + """Resolve every routing value that shared OAuth state can replace.""" + portal_url = ( + _optional_base_url(state.get("portal_base_url")) + or os.getenv("HERMES_PORTAL_BASE_URL") + or os.getenv("NOUS_PORTAL_BASE_URL") + or DEFAULT_NOUS_PORTAL_URL + ).rstrip("/") - # A persisted/stale portal_base_url is where the refresh token gets - # POSTed on refresh — reject any host outside the allowlist so a - # poisoned value can't exfiltrate the bearer, healing to the default. - # The trusted operator/deployment env override (HERMES_PORTAL_BASE_URL / - # NOUS_PORTAL_BASE_URL) bypasses this gate entirely — mirrors - # NOUS_INFERENCE_BASE_URL's treatment below; the allowlist exists to - # reject an untrusted NETWORK-provided value, not one the operator - # explicitly configured. - env_portal_override = _nous_portal_env_override() - if env_portal_override: - portal_base_url = env_portal_override.rstrip("/") - else: - parsed_portal_url = urlparse(portal_base_url) - if parsed_portal_url.hostname and parsed_portal_url.hostname not in _NOUS_PORTAL_ALLOWED_HOSTS: - logger.warning( - "auth: ignoring invalid portal_base_url %r (host %r not in allowlist), using default", - portal_base_url, parsed_portal_url.hostname, + # A persisted/stale portal_base_url is where the refresh token gets + # POSTed on refresh — reject any host outside the allowlist so a + # poisoned value can't exfiltrate the bearer, healing to the default. + # Trusted operator env overrides bypass this network-value gate. + env_portal_override = _nous_portal_env_override() + if env_portal_override: + portal_url = env_portal_override.rstrip("/") + else: + parsed_portal_url = urlparse(portal_url) + portal_host = parsed_portal_url.hostname + loopback_http = ( + parsed_portal_url.scheme == "http" + and portal_host in {"localhost", "127.0.0.1"} ) - portal_base_url = DEFAULT_NOUS_PORTAL_URL + trusted_scheme = ( + parsed_portal_url.scheme == "https" or loopback_http + ) + if ( + not portal_host + or portal_host not in _NOUS_PORTAL_ALLOWED_HOSTS + or not trusted_scheme + ): + logger.warning( + "auth: ignoring invalid portal_base_url %r " + "(host %r or scheme not allowed), using default", + portal_url, + portal_host, + ) + portal_url = DEFAULT_NOUS_PORTAL_URL - # Persisted value: validated network-provenance only. The stored - # inference_base_url is re-validated on read so a poisoned/stale - # staging host (persisted before the allowlist existed) heals to the - # production default on the no-refresh read path — this is what gets - # written back to auth.json. The env override is deliberately NOT - # folded in here: it must never be persisted (it's a runtime overlay). - stored_inference_base_url = ( - _validate_nous_inference_url_from_network( - _optional_base_url(state.get("inference_base_url")) + # Re-validate persisted network-provenance on every shared merge. + # The env override is runtime-only and must never be persisted. + stored_inference_url = ( + _validate_nous_inference_url_from_network( + _optional_base_url(state.get("inference_base_url")) + ) + or DEFAULT_NOUS_INFERENCE_URL ) - or DEFAULT_NOUS_INFERENCE_URL - ) - # Effective value used to build the client / returned to callers: - # the NOUS_INFERENCE_BASE_URL env override wins (documented dev/staging - # escape hatch), else the validated stored value. - inference_base_url = ( - _nous_inference_env_override() or stored_inference_base_url - ) - client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID) + effective_inference_url = ( + _nous_inference_env_override() or stored_inference_url + ) + effective_client_id = str( + state.get("client_id") or DEFAULT_NOUS_CLIENT_ID + ) + return ( + portal_url, + stored_inference_url, + effective_inference_url, + effective_client_id, + ) + + ( + portal_base_url, + stored_inference_base_url, + inference_base_url, + client_id, + ) = _resolve_effective_routing_metadata() def _persist_state(reason: str) -> None: nonlocal persisted_state, state_persisted @@ -5693,6 +5734,21 @@ def resolve_nous_runtime_credentials( access_token = state.get("access_token") refresh_token = state.get("refresh_token") + if not isinstance(access_token, str) or not access_token: + with _nous_shared_store_lock( + timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS) + ): + if _merge_shared_nous_oauth_state(state): + access_token = state.get("access_token") + refresh_token = state.get("refresh_token") + ( + portal_base_url, + stored_inference_base_url, + inference_base_url, + client_id, + ) = _resolve_effective_routing_metadata() + _persist_state("runtime_shared_merge_missing_access_token") + if not isinstance(access_token, str) or not access_token: raise AuthError("No access token found for Nous Portal login.", provider="nous", relogin_required=True) @@ -5707,6 +5763,12 @@ def resolve_nous_runtime_credentials( if _merge_shared_nous_oauth_state(state): access_token = state.get("access_token") refresh_token = state.get("refresh_token") + ( + portal_base_url, + stored_inference_base_url, + inference_base_url, + client_id, + ) = _resolve_effective_routing_metadata() invoke_jwt_status = _nous_invoke_jwt_status( access_token, scope=state.get("scope"), @@ -5771,6 +5833,11 @@ def resolve_nous_runtime_credentials( inference_base_url = ( _nous_inference_env_override() or stored_inference_base_url ) + # Persist network-derived routing with rotated tokens so + # a later JWT validation failure cannot leave the profile + # and shared stores on stale metadata. Never persist the + # operator-only env overlay. + state["inference_base_url"] = stored_inference_base_url state["obtained_at"] = now.isoformat() state["expires_in"] = access_ttl state["expires_at"] = datetime.fromtimestamp( diff --git a/hermes_cli/azure_detect.py b/hermes_cli/azure_detect.py index 1420d9334d6..7638ed6aca9 100644 --- a/hermes_cli/azure_detect.py +++ b/hermes_cli/azure_detect.py @@ -46,6 +46,8 @@ from urllib import request as urllib_request from urllib.error import HTTPError, URLError from urllib.parse import urlparse +from hermes_cli.urllib_security import open_credentialed_url + logger = logging.getLogger(__name__) @@ -158,7 +160,7 @@ def _http_get_json(url: str, _apply_auth_headers(req, token, mode) req.add_header("User-Agent", "hermes-agent/azure-detect") try: - with urllib_request.urlopen(req, timeout=timeout) as resp: + with open_credentialed_url(req, timeout=timeout) as resp: body = resp.read() try: return resp.status, json.loads(body.decode("utf-8", errors="replace")) @@ -269,7 +271,7 @@ def _probe_anthropic_messages(base_url: str, req.add_header("content-type", "application/json") req.add_header("User-Agent", "hermes-agent/azure-detect") try: - with urllib_request.urlopen(req, timeout=6.0) as resp: + with open_credentialed_url(req, timeout=6.0) as resp: # Should never 200 — "probe" isn't a real deployment. But # if it does, the endpoint definitely speaks Anthropic. return resp.status < 500 diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index a71d8835698..d3c967405ad 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -390,6 +390,7 @@ class CLIAgentSetupMixin: tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None, notice_callback=self._on_notice, notice_clear_callback=self._on_notice_clear, + reaction_callback=self._on_reaction, ) # Store reference for atexit memory provider shutdown. # NOTE: this MUST write to the ``cli`` module's global, not a diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index b16d2166e2c..c8bf1e67136 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2471,7 +2471,7 @@ class CLICommandsMixin: Usage: /reasoning Show current effort level and display state - /reasoning <level> Set reasoning effort (none, minimal, low, medium, high, xhigh) + /reasoning <level> Set effort (none, minimal, low, medium, high, xhigh, max, ultra) /reasoning show|on Show model thinking/reasoning in output /reasoning hide|off Hide model thinking/reasoning from output /reasoning full Show complete thinking (no 10-line clamp) @@ -2493,7 +2493,7 @@ class CLICommandsMixin: full_state = "full" if getattr(self, "reasoning_full", False) else "clamped to 10 lines" _cprint(f" {_ACCENT}Reasoning effort: {level}{_RST}") _cprint(f" {_ACCENT}Reasoning display: {display_state} ({full_state}){_RST}") - _cprint(f" {_DIM}Usage: /reasoning <none|minimal|low|medium|high|xhigh|show|hide|full|clamp>{_RST}") + _cprint(f" {_DIM}Usage: /reasoning <none|minimal|low|medium|high|xhigh|max|ultra|show|hide|full|clamp>{_RST}") return arg = parts[1].strip().lower() @@ -2534,7 +2534,7 @@ class CLICommandsMixin: parsed = _parse_reasoning_config(arg) if parsed is None: _cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}") - _cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh{_RST}") + _cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh, max, ultra{_RST}") _cprint(f" {_DIM}Display: show, hide{_RST}") return diff --git a/hermes_cli/codex_models.py b/hermes_cli/codex_models.py index 768e68bee38..a56cddf73a2 100644 --- a/hermes_cli/codex_models.py +++ b/hermes_cli/codex_models.py @@ -12,6 +12,14 @@ import os logger = logging.getLogger(__name__) DEFAULT_CODEX_MODELS: List[str] = [ + # GPT-5.6 series (Sol/Terra/Luna + -pro high-effort modes) — GA 2026-07-09 + # (previewed 2026-06-26). + "gpt-5.6-sol", + "gpt-5.6-sol-pro", + "gpt-5.6-terra", + "gpt-5.6-terra-pro", + "gpt-5.6-luna", + "gpt-5.6-luna-pro", "gpt-5.5", "gpt-5.4-mini", "gpt-5.4", @@ -44,6 +52,12 @@ DEFAULT_CODEX_MODELS: List[str] = [ ] _FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [ + ("gpt-5.6-sol", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-sol-pro", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-terra", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-terra-pro", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-luna", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-luna-pro", ("gpt-5.5", "gpt-5.4")), ("gpt-5.5", ("gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex")), ("gpt-5.4-mini", ("gpt-5.3-codex",)), ("gpt-5.4", ("gpt-5.3-codex",)), diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 3e2d03dc358..4f32f7315cb 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -154,7 +154,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ "Configuration"), CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", args_hint="[level|show|hide|full|clamp]", - subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "show", "hide", "on", "off", "full", "clamp")), + subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp")), CommandDef("fast", "Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode (Normal/Fast)", "Configuration", args_hint="[normal|fast|status]", subcommands=("normal", "fast", "status", "on", "off")), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7c83c6f167a..b95b1c666a8 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -93,7 +93,9 @@ def _backup_corrupt_config(config_path: Path) -> Optional[Path]: return None -def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: +def _warn_config_parse_failure( + config_path: Path, exc: Exception, *, fallback: str = "defaults" +) -> None: """Surface a config.yaml parse failure to user, log, and stderr. A YAML parse error in ``~/.hermes/config.yaml`` causes ``load_config()`` @@ -110,6 +112,11 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: timestamped ``.bak`` (best-effort) so the user's recoverable content survives any later rewrite of ``config.yaml`` by the setup wizard or ``hermes config set``. + + ``fallback`` selects the message wording: ``"defaults"`` (fresh process, + nothing else to serve) or ``"last-known-good"`` (in-process retention of + the previously loaded config — see the codex#31188 port in + ``_load_config_impl``). """ try: st = config_path.stat() @@ -122,12 +129,19 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: backup_path = _backup_corrupt_config(config_path) - msg = ( - f"Failed to parse {config_path}: {exc}. " - f"Falling back to default config — every user override " - f"(auxiliary providers, fallback chain, model settings) is being IGNORED. " - f"Fix the YAML and restart." - ) + if fallback == "last-known-good": + msg = ( + f"Failed to parse {config_path}: {exc}. " + f"Keeping the previously loaded config for this process — " + f"edits to config.yaml are being IGNORED until the YAML is fixed." + ) + else: + msg = ( + f"Failed to parse {config_path}: {exc}. " + f"Falling back to default config — every user override " + f"(auxiliary providers, fallback chain, model settings) is being IGNORED. " + f"Fix the YAML and restart." + ) if backup_path is not None: msg += f" A copy of the corrupted file was saved to {backup_path}." logger.warning(msg) @@ -1423,17 +1437,18 @@ DEFAULT_CONFIG = { # True if you'd rather pause than silently lose # context turns when your aux model is flaky. "codex_gpt55_autoraise": True, # Historical key name kept for compatibility. - # When True, gpt-5.4 / gpt-5.5 on the ChatGPT Codex - # OAuth route raise their compaction trigger to 85% - # (vs the global `threshold` above). Codex hard-caps - # both families at a 272K window, so the default 50% - # would compact at ~136K and waste half the usable - # context. Set to False to opt back down to the global - # threshold (e.g. 0.50) for those Codex sessions. - # Only this exact route is affected — gpt-5.4 / 5.5 - # on OpenAI's direct API, OpenRouter, and Copilot keep - # the global threshold regardless. - "codex_gpt55_autoraise_notice": True, # Display the one-time Codex gpt-5.4/5.5 + # When True, gpt-5.4 / gpt-5.5 / gpt-5.6 on the + # ChatGPT Codex OAuth route raise their compaction + # trigger to 85% (vs the global `threshold` above). + # Codex hard-caps these families at a 272K window, so + # the default 50% would compact at ~136K and waste half + # the usable context. Set to False to opt back down to + # the global threshold (e.g. 0.50) for those Codex + # sessions. Only this exact route is affected — + # gpt-5.4 / 5.5 / 5.6 on OpenAI's direct API, + # OpenRouter, and Copilot keep the global threshold + # regardless. + "codex_gpt55_autoraise_notice": True, # Display the one-time Codex gpt-5.4/5.5/5.6 # autoraise banner. Set False to keep the # 85% threshold autoraise but suppress the # user-facing notice in CLI/gateway output. @@ -2049,7 +2064,9 @@ DEFAULT_CONFIG = { # limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware, # Gemini 32000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000). "tts": { - "provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local) + # Set explicitly to pin a backend: + # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "deepinfra" | "neutts" (local) | "kittentts" (local) | "piper" (local) + "provider": "edge", "edge": { "voice": "en-US-AriaNeural", # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural @@ -2105,15 +2122,20 @@ DEFAULT_CONFIG = { # "volume": 1.0, # "normalize_audio": True, }, + "deepinfra": { + "model": "", # empty = first tts-tagged model from the live catalog + "voice": "default", + # "base_url": "", # override DEEPINFRA_BASE_URL for TTS only + }, }, - + "stt": { "enabled": True, # When true, gateway voice messages are transcribed for the agent and # the raw transcript is also echoed back to the user as a 🎙️ message. # Set false to keep STT for the agent while suppressing that user-facing echo. "echo_transcripts": True, - "provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "elevenlabs" (Scribe) + "provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "elevenlabs" (Scribe) | "deepinfra" "local": { "model": "base", # tiny, base, small, medium, large-v3 "language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force @@ -2130,6 +2152,10 @@ DEFAULT_CONFIG = { "tag_audio_events": False, "diarize": False, }, + "deepinfra": { + "model": "", # empty = first stt-tagged model from the live catalog + # "base_url": "", # override DEEPINFRA_BASE_URL for STT only + }, }, "voice": { @@ -2225,8 +2251,8 @@ DEFAULT_CONFIG = { # (API, tools, iteration budget), never a delegation # stopwatch. Set a positive number of seconds # (floor 30s) to enforce a hard cap. - "reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium", - # "low", "minimal", "none" (empty = inherit parent's level) + "reasoning_effort": "", # subagent effort: "ultra", "max", "xhigh", "high", + # "medium", "low", "minimal", "none" (empty = inherit) "max_concurrent_children": 3, # unified concurrency cap: max parallel children per batch # AND max concurrent background (background=true) # delegation units. New async dispatches beyond the cap @@ -2508,15 +2534,15 @@ DEFAULT_CONFIG = { }, # Approval mode for dangerous commands: - # manual — always prompt the user (default) - # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk + # manual — always prompt the user + # smart — use auxiliary LLM to auto-approve low-risk commands (default) # off — skip all approval prompts (equivalent to --yolo) # # cron_mode — what to do when a cron job hits a dangerous command: # deny — block the command and let the agent find another way (default, safe) # approve — auto-approve all dangerous commands in cron jobs "approvals": { - "mode": "manual", + "mode": "smart", "timeout": 60, "cron_mode": "deny", # User-defined deny rules: fnmatch globs matched against terminal @@ -2679,6 +2705,12 @@ DEFAULT_CONFIG = { # recent .md files and prunes older ones. 0 or negative disables # pruning (for operators who manage cleanup externally). Default 50. "output_retention": 50, + # Timeout (seconds) for SessionDB() init inside cron jobs. + # SessionDB opens/migrates state.db synchronously and has no timeout + # of its own against a wedged sqlite3.connect. An unbounded hang here + # wedges the job's dispatch guard forever. Also overridable via + # HERMES_CRON_SESSION_DB_TIMEOUT env var. 0 = unlimited (skip the bound). + "session_db_timeout_seconds": 10, }, # Kanban multi-agent coordination — controls the dispatcher loop that @@ -3520,6 +3552,14 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, + "FIREWORKS_API_KEY": { + "description": "Fireworks AI API key", + "prompt": "Fireworks AI API key", + "url": "https://app.fireworks.ai/settings/users/api-keys", + "password": True, + "category": "provider", + "advanced": True, + }, "MINIMAX_API_KEY": { "description": "MiniMax API key (international)", "prompt": "MiniMax API key", @@ -3698,7 +3738,6 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, - # ── Tool API keys ── "EXA_API_KEY": { "description": "Exa API key for AI-native web search and contents", @@ -6228,6 +6267,12 @@ def _deep_merge(base: dict, override: dict) -> dict: Keys in *override* take precedence. If both values are dicts the merge recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will keep the default ``tts.elevenlabs.model_id`` intact. + + An empty section key in config.yaml (``terminal:`` with no value) parses + as YAML ``None``; treating that as an override would replace the entire + default dict with ``None`` and crash every downstream consumer that + expects a mapping (#58277). A ``None`` override of a dict default is + ignored — same as the key being absent. """ result = base.copy() for key, value in override.items(): @@ -6237,6 +6282,8 @@ def _deep_merge(base: dict, override: dict) -> dict: and isinstance(value, dict) ): result[key] = _deep_merge(result[key], value) + elif key in result and isinstance(result[key], dict) and value is None: + continue else: result[key] = value return result @@ -6935,7 +6982,45 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]: config = _deep_merge(config, user_config) except Exception as e: - _warn_config_parse_failure(config_path, e) + # Last-known-good fallback (port of openai/codex#31188's + # invariant: a parse failure in a policy/config file must not + # silently replace the effective policy with an empty/default + # one). Falling through to DEFAULT_CONFIG here drops EVERY user + # override — including security-critical ``approvals.deny`` + # rules, which are supposed to block commands even under yolo. + # A long-running gateway whose user mid-edits config.yaml into + # broken YAML would silently lose those rules on the next load. + # Within a running process we still have the last successfully + # loaded config — keep serving it until the file is fixed. + # Fresh processes with no last-known-good keep the existing + # DEFAULT_CONFIG fallback. + lkg = _LAST_EXPANDED_CONFIG_BY_PATH.get(path_key) + _warn_config_parse_failure( + config_path, + e, + fallback="last-known-good" if lkg is not None else "defaults", + ) + if lkg is not None: + # save_config() stores the pre-expansion normalized dict + # (env-ref templates preserved); the load path stores the + # expanded one. Expand defensively — idempotent when the + # stored value is already expanded. + from typing import cast as _cast + lkg_copy: Dict[str, Any] = _cast( + Dict[str, Any], _expand_env_vars(copy.deepcopy(lkg)) + ) + if cache_sig is not None: + # Cache under the corrupt file's signature (empty env + # snapshot: always valid) so repeated loads don't + # re-parse the broken file; fixing the file changes the + # signature and triggers a normal reload. + _empty_env: Dict[str, Optional[str]] = {} + _LOAD_CONFIG_CACHE[path_key] = ( + cache_sig[0], cache_sig[1], + cache_sig[2], cache_sig[3], + lkg_copy, _empty_env, + ) + return copy.deepcopy(lkg_copy) if want_deepcopy else lkg_copy normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) expanded = _expand_env_vars(normalized) @@ -8028,6 +8113,20 @@ def edit_config(): subprocess.run([editor, str(config_path)]) +def _default_value_for_key(dotted_key: str): + """Return the leaf value declared for *dotted_key* in ``DEFAULT_CONFIG``. + + Unknown keys and non-leaf paths return ``None`` so they retain the legacy + best-effort coercion used by ``config set``. + """ + node = DEFAULT_CONFIG + for part in dotted_key.split("."): + if not isinstance(node, dict) or part not in node: + return None + node = node[part] + return node if not isinstance(node, dict) else None + + def set_config_value(key: str, value: str): """Set a configuration value.""" if is_managed(): @@ -8085,16 +8184,21 @@ def set_config_value(key: str, value: str): # _set_nested which preserves list-typed nodes; before #17876 the # inline navigation here silently overwrote lists with dicts. - # Convert value to appropriate type - if value.lower() in {'true', 'yes', 'on'}: - value = True - elif value.lower() in {'false', 'no', 'off'}: - value = False - elif value.isdigit(): - value = int(value) - elif value.replace('.', '', 1).isdigit(): - value = float(value) + # Preserve values for string-typed settings. In particular, enum members + # such as approvals.mode="off" must not become YAML booleans. Unknown keys + # retain the historical best-effort coercion behavior. + coerced_value: Any = value + if not isinstance(_default_value_for_key(key), str): + if value.lower() in {'true', 'yes', 'on'}: + coerced_value = True + elif value.lower() in {'false', 'no', 'off'}: + coerced_value = False + elif value.isdigit(): + coerced_value = int(value) + elif value.replace('.', '', 1).isdigit(): + coerced_value = float(value) + value = coerced_value _set_nested(user_config, key, value) # Normalize the api_base → base_url alias at set-time too (issue #8919), # so a fresh `hermes config set model.api_base ...` lands on the canonical diff --git a/hermes_cli/dashboard_auth/middleware.py b/hermes_cli/dashboard_auth/middleware.py index 2c5f5b4f7b9..362ed729d65 100644 --- a/hermes_cli/dashboard_auth/middleware.py +++ b/hermes_cli/dashboard_auth/middleware.py @@ -150,6 +150,8 @@ def _auto_sso_response(request: Request) -> Response | None: * exactly ONE interactive provider is registered — with two or more we can't pick for the user, so the ``/login`` chooser must render; with zero there's nothing to redirect to; + * that provider is OAuth-style, not a password form provider. Password + providers must render ``/login`` so the user can enter credentials; * the one-shot loop-guard marker is ABSENT. Its presence means we already bounced to the portal once and came back still unauthenticated (no portal session) — auto-redirecting again would @@ -185,6 +187,9 @@ def _auto_sso_response(request: Request) -> Response | None: from hermes_cli.dashboard_auth.prefix import prefix_from_request provider = providers[0] + if getattr(provider, "supports_password", False): + return None + prefix = prefix_from_request(request) next_param = _safe_next_target(request) from urllib.parse import quote @@ -458,4 +463,3 @@ def _attempt_refresh(request: Request, *, refresh_token): if new_session is not None: return new_session, provider.name return None - diff --git a/hermes_cli/dashboard_auth/routes.py b/hermes_cli/dashboard_auth/routes.py index 9e80f2583c5..ee595be7d82 100644 --- a/hermes_cli/dashboard_auth/routes.py +++ b/hermes_cli/dashboard_auth/routes.py @@ -192,6 +192,14 @@ async def auth_login(request: Request, provider: str, next: str = ""): status_code=404, detail=f"Provider does not support interactive login: {provider!r}", ) + if getattr(p, "supports_password", False): + from urllib.parse import quote + + safe_next = _validate_post_login_target(next) + login_url = f"{_prefix(request)}/login" + if safe_next: + login_url = f"{login_url}?next={quote(safe_next, safe='')}" + return RedirectResponse(url=login_url, status_code=302) try: ls = p.start_login(redirect_uri=_redirect_uri(request)) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 12b688b224c..6e94996c274 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -30,6 +30,7 @@ from utils import base_url_host_matches _PROVIDER_ENV_HINTS = ( + "DEEPINFRA_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", @@ -42,6 +43,7 @@ _PROVIDER_ENV_HINTS = ( "KIMI_API_KEY", "KIMI_CN_API_KEY", "GMI_API_KEY", + "FIREWORKS_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "KILOCODE_API_KEY", @@ -845,6 +847,14 @@ def run_doctor(args): "lmstudio", "nous", "nvidia", + # Fireworks' native model IDs are slash-form + # (accounts/fireworks/models/... and .../routers/...), so a "/" + # is expected, not an aggregator vendor prefix. + "fireworks", + # DeepInfra is an aggregator-style gateway: its catalog + # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…, + # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …). + "deepinfra", } provider_accepts_vendor_slug = ( provider_policy_id in providers_accepting_vendor_slugs diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index ab743b281f1..7b9817eadd8 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -938,6 +938,32 @@ def _read_systemd_unit_environment(system: bool = False) -> dict[str, str]: return parsed +def _hermes_home_from_systemd_unit_file(system: bool = False) -> str | None: + """Read ``HERMES_HOME`` from the on-disk unit file (not ``systemctl show``). + + Prefer the file when refreshing/comparing: under ``sudo``, ``systemctl`` + may be slow/unavailable in tests, and the on-disk unit is what + ``systemd_unit_is_current`` / ``refresh_systemd_unit_if_needed`` already + compare against. + """ + unit_path = get_systemd_unit_path(system=system) + if not unit_path.exists(): + return None + try: + text = unit_path.read_text(encoding="utf-8") + except OSError: + return None + for line in text.splitlines(): + stripped = line.strip() + if not stripped.startswith("Environment="): + continue + body = stripped[len("Environment=") :].strip().strip('"') + if body.startswith("HERMES_HOME="): + value = body.split("=", 1)[1].strip().strip('"') + return value or None + return None + + def _sync_hermes_home_from_systemd_unit(system: bool) -> None: """When acting on a system-scope unit, adopt its ``HERMES_HOME``. @@ -949,8 +975,11 @@ def _sync_hermes_home_from_systemd_unit(system: bool) -> None: """ if not system: return - env = _read_systemd_unit_environment(system=True) - unit_home = env.get("HERMES_HOME", "").strip() + # Prefer the on-disk unit (source of truth for refresh/compare). Fall + # back to ``systemctl show`` for units that only exist in the manager. + unit_home = (_hermes_home_from_systemd_unit_file(system=True) or "").strip() + if not unit_home: + unit_home = _read_systemd_unit_environment(system=True).get("HERMES_HOME", "").strip() if not unit_home: return current = os.environ.get("HERMES_HOME", "").strip() @@ -2826,6 +2855,24 @@ def _normalize_launchd_plist_for_comparison(text: str) -> str: def systemd_unit_is_current(system: bool = False) -> bool: + # ── HERMES_HOME sync chokepoint ────────────────────────────────────── + # Every path that compares OR regenerates the unit funnels through here: + # ``refresh_systemd_unit_if_needed`` gates on this before rewriting, and + # ``systemd_status`` / ``systemd_install`` call it directly. Doing the + # sync here — and ONLY here — enforces the invariant "the operator's + # pinned HERMES_HOME is adopted before any compare/regenerate" at a single + # site, so a future callsite cannot regress it by forgetting to pre-sync. + # + # Under ``sudo hermes gateway … --system``, HERMES_HOME is often stripped + # and falls back to ``/root/.hermes``. Adopting the unit's pinned home + # first makes TimeoutStopSec / WorkingDirectory / HERMES_HOME comparisons + # use the real operator config — otherwise start/restart "refresh" rewrites + # a correct unit from root's defaults and ``status`` keeps warning forever. + # ``_sync_...`` is idempotent (early-returns once os.environ matches), so + # the mutation persists for callers that read runtime state after this + # (e.g. ``systemd_restart``'s post-refresh get_running_pid / drain-timeout). + _sync_hermes_home_from_systemd_unit(system=system) + unit_path = get_systemd_unit_path(system=system) if not unit_path.exists(): return False @@ -2907,7 +2954,14 @@ def _refuse_temp_home_service_write(definition: str, kind: str) -> bool: def refresh_systemd_unit_if_needed(system: bool = False) -> bool: """Rewrite the installed systemd unit when the generated definition has changed.""" unit_path = get_systemd_unit_path(system=system) - if not unit_path.exists() or systemd_unit_is_current(system=system): + if not unit_path.exists(): + return False + + # The gate below funnels through ``systemd_unit_is_current``, which is the + # single HERMES_HOME-sync chokepoint (adopts the unit's pinned home before + # any compare/regenerate). No separate pre-sync needed here — and the env + # mutation it performs persists for the regenerate path below. + if systemd_unit_is_current(system=system): return False expected_user = _read_systemd_user_from_unit(unit_path) if system else None @@ -3094,6 +3148,15 @@ def systemd_install( unit_path = get_systemd_unit_path(system=system) scope_flag = " --system" if system else "" + # Existing system units already pin HERMES_HOME; adopt it before any + # regenerate. This pre-sync is NOT redundant with the systemd_unit_is_current + # chokepoint: the ``--force`` path below skips the is_current gate and calls + # generate_systemd_unit() directly (line ~3172), so without this a + # ``sudo hermes gateway install --system --force`` would bake /root/.hermes + # into an already-correct unit. Keep it to protect that bypass path. + if unit_path.exists(): + _sync_hermes_home_from_systemd_unit(system=system) + if unit_path.exists() and not force: if not systemd_unit_is_current(system=system): print( @@ -3184,6 +3247,9 @@ def systemd_start(system: bool = False): # Raises UserSystemdUnavailableError with a remediation message. _preflight_user_systemd() _require_service_installed("start", system=system) + # HERMES_HOME sync happens inside refresh_systemd_unit_if_needed's + # systemd_unit_is_current gate (the single chokepoint), and the unit is + # guaranteed to exist here by _require_service_installed, so the gate runs. refresh_systemd_unit_if_needed(system=system) _run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30) print(f"✓ {_service_scope_label(system).capitalize()} service started") @@ -3224,8 +3290,12 @@ def systemd_restart(system: bool = False): else: _preflight_user_systemd() _require_service_installed("restart", system=system) + # HERMES_HOME sync happens inside refresh_systemd_unit_if_needed's + # systemd_unit_is_current gate (the single chokepoint). The unit exists + # here (_require_service_installed), so the gate runs and its os.environ + # mutation persists for the get_running_pid / drain-timeout reads below — + # no separate pre-sync needed. refresh_systemd_unit_if_needed(system=system) - _sync_hermes_home_from_systemd_unit(system=system) from gateway.status import get_running_pid pid = get_running_pid() or _systemd_main_pid(system=system) @@ -3326,8 +3396,6 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) print(f" Run: {'sudo ' if system else ''}hermes gateway install{scope_flag}") return - _sync_hermes_home_from_systemd_unit(system=system) - if has_conflicting_systemd_units(): print_systemd_scope_conflict_warning() print() diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 8741a6be04f..c2668f3fa56 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -189,6 +189,14 @@ def build_models_payload( if explicit_only: rows = _filter_explicit_provider_rows(rows, ctx) + # Desktop chat pickers request the explicit subset without the full + # unconfigured provider universe. If the configured current provider + # has lost its credential, list_authenticated_providers() omits it; + # keep that one row visible so the UI can show the saved selection and + # a re-auth affordance instead of appearing to jump to another provider. + rows = list(rows) + _append_unconfigured_rows( + rows, ctx, current_only=True + ) # --- Deduplicate: remove models from aggregators that overlap with # user-defined providers. When a local proxy (e.g. litellm-proxy) @@ -397,16 +405,62 @@ _OLLAMA_META_IN_FLIGHT: set = set() # ─── Internal: row post-processing ────────────────────────────────────── -def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]: - """Build skeleton rows for canonical providers missing from ``rows``.""" +def _append_unconfigured_rows( + rows: list[dict], + ctx: ConfigContext, + *, + current_only: bool = False, +) -> list[dict]: + """Build fallback rows for canonical providers missing from ``rows``. + + Most missing canonical providers become empty setup skeletons. The one + exception is the *current* configured provider: if config.yaml still points + at it but credentials are presently unavailable, keep a visible row carrying + the saved model so GUI pickers don't silently snap to some other provider. + """ + from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS seen = {r["slug"].lower() for r in rows} cur = (ctx.current_provider or "").lower() + cur_model = str(ctx.current_model or "").strip() extras: list[dict] = [] for entry in CANONICAL_PROVIDERS: if entry.slug.lower() in seen: continue + if current_only and entry.slug.lower() != cur: + continue + if entry.slug.lower() == cur: + cfg = PROVIDER_REGISTRY.get(entry.slug) + auth_type = cfg.auth_type if cfg else "api_key" + key_env = ( + cfg.api_key_env_vars[0] + if (cfg and cfg.api_key_env_vars) + else "" + ) + warning = ( + f"Configured provider missing usable credentials; paste {key_env} to reactivate. " + "Showing the saved model only." + if auth_type == "api_key" and key_env + else "Configured provider is not authenticated; run `hermes model` to reactivate. " + "Showing the saved model only." + ) + extras.append( + { + "slug": entry.slug, + "name": _PROVIDER_LABELS.get(entry.slug, entry.label), + "is_current": True, + "is_user_defined": False, + "models": [cur_model] if cur_model else [], + "total_models": 1 if cur_model else 0, + "source": "configured-current", + "authenticated": False, + "auth_type": auth_type, + "key_env": key_env, + "warning": warning, + } + ) + continue extras.append( { "slug": entry.slug, @@ -445,7 +499,11 @@ def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list if slug == "moa": # MoA is a virtual routing mode, not an independently configured # provider. Hide it from explicit-only pickers unless it is the - # current provider (handled above). + # current provider (handled above) or the user explicitly wrote an + # enabled MoA preset into config.yaml. Use raw config so the + # DEFAULT_CONFIG preset does not make every desktop picker show MoA. + if _raw_config_has_enabled_moa_preset(): + kept.append(row) continue if slug == "ollama": # Local Ollama servers are keyless by design — reachability is @@ -460,6 +518,50 @@ def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list return kept +def _raw_config_has_enabled_moa_preset() -> bool: + """Return True when the user's raw config explicitly enables MoA. + + ``load_config()`` includes ``DEFAULT_CONFIG["moa"].presets.default`` for + everyone. Explicit-only model pickers must not treat that default as a user + choice, but they should keep MoA visible once the user has saved at least + one enabled preset (or an older flat MoA config) in their own config.yaml. + """ + try: + from hermes_cli.config import read_raw_config + + raw = read_raw_config() + except Exception: + return False + + if not isinstance(raw, dict): + return False + moa = raw.get("moa") + if not isinstance(moa, dict): + return False + + presets = moa.get("presets") + if isinstance(presets, dict): + for name, preset in presets.items(): + if not str(name or "").strip(): + continue + if not isinstance(preset, dict): + return True + if preset.get("enabled", True): + return True + return False + + legacy_keys = { + "reference_models", + "aggregator", + "reference_temperature", + "aggregator_temperature", + "max_tokens", + "reference_max_tokens", + "fanout", + } + return any(key in moa for key in legacy_keys) and bool(moa.get("enabled", True)) + + def _apply_picker_hints(rows: list[dict]) -> None: """Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row. diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 6150b141537..ed617c8634a 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -135,6 +135,7 @@ BLOCK_RECURRENCE_LIMIT = 2 VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names()) _IS_WINDOWS = sys.platform == "win32" +KANBAN_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024 def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None: @@ -3975,6 +3976,10 @@ class HallucinatedCardsError(ValueError): ) +class ArtifactPreservationError(RuntimeError): + """Raised when a declared scratch deliverable cannot be preserved.""" + + def complete_task( conn: sqlite3.Connection, task_id: str, @@ -4042,6 +4047,9 @@ def complete_task( else: verified_cards = [] + metadata = _merge_completion_prose_artifacts( + conn, task_id, metadata, summary=summary, result=result, + ) with write_txn(conn): if expected_run_id is None: cur = conn.execute( @@ -4080,6 +4088,18 @@ def complete_task( ) if cur.rowcount != 1: return False + if isinstance(metadata, dict): + _persist_scratch_completion_artifacts(conn, task_id, metadata) + for stored_path in metadata.pop("_staged_artifacts", []): + path = Path(stored_path) + _insert_completion_attachment( + conn, + task_id, + filename=path.name, + stored_path=str(path), + size=path.stat().st_size, + created_at=now, + ) run_id = _end_run( conn, task_id, outcome="completed", status="done", @@ -4174,6 +4194,256 @@ def complete_task( # Workspace / tmux cleanup # --------------------------------------------------------------------------- + +def _merge_completion_prose_artifacts( + conn: sqlite3.Connection, + task_id: str, + metadata: Optional[dict], + *, + summary: Optional[str], + result: Optional[str], +) -> Optional[dict]: + """Promote existing scratch files named in legacy completion prose. + + ``artifacts=[...]`` is preferred. Older workers only wrote an absolute + deliverable path in ``summary``/``result``; discover it while scratch still + exists so cleanup cannot erase the file the user was promised. + """ + row = conn.execute( + "SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not row or row["workspace_kind"] != "scratch" or not row["workspace_path"]: + return metadata + workspace = Path(row["workspace_path"]).expanduser() + if not _is_managed_scratch_path(workspace): + return metadata + text = "\n".join(part for part in (summary, result) if part) + if not text: + return metadata + prefix = re.escape(str(workspace)) + discovered: list[str] = [] + for match in re.finditer(prefix + r"(?:[/\\][^\s`\"'<>]+)", text): + raw = match.group(0).rstrip(".,;:!?)]}") + candidate = Path(raw) + if candidate.is_file(): + discovered.append(str(candidate)) + if not discovered: + return metadata + updated = dict(metadata) if isinstance(metadata, dict) else {} + existing = updated.get("artifacts") + merged = list(existing) if isinstance(existing, (list, tuple)) else [] + seen = {str(path) for path in merged} + for path in discovered: + if path not in seen: + merged.append(path) + seen.add(path) + updated["artifacts"] = merged + return updated + + +def _persist_scratch_completion_artifacts( + conn: sqlite3.Connection, + task_id: str, + metadata: dict, +) -> None: + """Copy scratch-workspace completion artifacts before cleanup removes them.""" + raw_artifacts = metadata.get("artifacts") + if not isinstance(raw_artifacts, (list, tuple)): + return + + row = conn.execute( + "SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not row or row["workspace_kind"] != "scratch" or not row["workspace_path"]: + return + + workspace = Path(row["workspace_path"]).expanduser() + is_managed, board = _managed_scratch_path_info(workspace) + if not is_managed: + return + + try: + workspace_root = workspace.resolve() + except OSError: + return + + attachment_dir = task_attachments_dir(task_id, board=board) + persisted: list[str] = [] + used_destinations: set[Path] = set() + changed = False + + def _discard_copies() -> None: + for copied in used_destinations: + try: + copied.unlink(missing_ok=True) + except OSError: + pass + try: + attachment_dir.rmdir() + except OSError: + pass + + for item in raw_artifacts: + artifact = str(item).strip() if isinstance(item, str) else "" + if not artifact: + continue + src = Path(artifact).expanduser() + try: + resolved_src = src.resolve() + except OSError: + persisted.append(artifact) + continue + + if not resolved_src.is_relative_to(workspace_root): + persisted.append(artifact) + continue + + if not src.is_file(): + _discard_copies() + raise ArtifactPreservationError( + f"declared scratch artifact is unavailable or not a regular file: {artifact}" + ) + + size = resolved_src.stat().st_size + if size > KANBAN_ATTACHMENT_MAX_BYTES: + _discard_copies() + raise ArtifactPreservationError( + f"declared scratch artifact exceeds the " + f"{KANBAN_ATTACHMENT_MAX_BYTES}-byte limit: {artifact}" + ) + + dest: Optional[Path] = None + try: + attachment_dir.mkdir(parents=True, exist_ok=True) + dest = _unique_attachment_path(attachment_dir, resolved_src.name, used_destinations) + with resolved_src.open("rb") as source_file, dest.open("xb") as destination_file: + copied = 0 + while chunk := source_file.read(1024 * 1024): + copied += len(chunk) + if copied > KANBAN_ATTACHMENT_MAX_BYTES: + raise ArtifactPreservationError( + f"declared scratch artifact grew beyond the size limit: {artifact}" + ) + destination_file.write(chunk) + except Exception as exc: + if dest is not None: + try: + dest.unlink(missing_ok=True) + except OSError: + pass + _discard_copies() + if isinstance(exc, ArtifactPreservationError): + raise + raise ArtifactPreservationError( + f"could not preserve declared scratch artifact {artifact}: {exc}" + ) from exc + + used_destinations.add(dest) + persisted.append(str(dest.resolve())) + changed = True + + if changed: + metadata["artifacts"] = persisted + metadata["_staged_artifacts"] = [ + path for path in persisted if path.startswith(str(attachment_dir.resolve())) + ] + + +def _insert_completion_attachment( + conn: sqlite3.Connection, + task_id: str, + *, + filename: str, + stored_path: str, + size: int, + created_at: int, +) -> None: + """Record a worker-produced artifact in the existing attachment table.""" + conn.execute( + "INSERT INTO task_attachments " + "(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) " + "VALUES (?, ?, ?, NULL, ?, 'kanban_complete', ?)", + (task_id, filename, stored_path, size, created_at), + ) + _append_event( + conn, + task_id, + "attached", + {"filename": filename, "size": size, "by": "kanban_complete"}, + ) + + +def _unique_attachment_path(directory: Path, filename: str, used: set[Path]) -> Path: + """Return a non-conflicting path under ``directory`` for ``filename``.""" + safe_name = Path(filename).name or "artifact" + candidate = directory / safe_name + if candidate not in used and not candidate.exists(): + return candidate + + stem = Path(safe_name).stem or "artifact" + suffix = Path(safe_name).suffix + idx = 1 + while True: + candidate = directory / f"{stem}_{idx}{suffix}" + if candidate not in used and not candidate.exists(): + return candidate + idx += 1 + + +def _managed_scratch_path_info(p: Path) -> tuple[bool, Optional[str]]: + """Return whether *p* is managed scratch storage and the matching board.""" + try: + p_abs = p.resolve(strict=False) + except OSError: + return False, None + roots: list[tuple[Path, Optional[str]]] = [] + override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip() + if override: + try: + roots.append((Path(override).expanduser().resolve(strict=False), None)) + except OSError: + pass + try: + home = kanban_home() + except OSError: + home = None + if home is not None: + try: + roots.append(((home / "kanban" / "workspaces").resolve(strict=False), DEFAULT_BOARD)) + except OSError: + pass + try: + boards_parent = (home / "kanban" / "boards").resolve(strict=False) + except OSError: + boards_parent = None + if boards_parent is not None: + try: + entries = list(boards_parent.iterdir()) + except OSError: + entries = [] + for entry in entries: + try: + if not entry.is_dir(): + continue + except OSError: + continue + try: + roots.append(((entry / "workspaces").resolve(strict=False), entry.name)) + except OSError: + continue + for root, board in roots: + if p_abs == root: + continue + try: + if p_abs.is_relative_to(root): + return True, board + except ValueError: + continue + return False, None + + def _is_managed_scratch_path(p: Path) -> bool: """Return True iff *p* is a strict descendant of a kanban-managed scratch root. @@ -4199,54 +4469,8 @@ def _is_managed_scratch_path(p: Path) -> bool: real source tree can otherwise pair with ``workspace_kind='scratch'`` and cause task completion to delete user data (#28818). """ - try: - p_abs = p.resolve(strict=False) - except OSError: - return False - roots: list[Path] = [] - override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip() - if override: - try: - roots.append(Path(override).expanduser().resolve(strict=False)) - except OSError: - pass - try: - home = kanban_home() - except OSError: - home = None - if home is not None: - try: - roots.append((home / "kanban" / "workspaces").resolve(strict=False)) - except OSError: - pass - try: - boards_parent = (home / "kanban" / "boards").resolve(strict=False) - except OSError: - boards_parent = None - if boards_parent is not None: - try: - entries = list(boards_parent.iterdir()) - except OSError: - entries = [] - for entry in entries: - try: - if not entry.is_dir(): - continue - except OSError: - continue - try: - roots.append((entry / "workspaces").resolve(strict=False)) - except OSError: - continue - for root in roots: - if p_abs == root: - continue - try: - if p_abs.is_relative_to(root): - return True - except ValueError: - continue - return False + is_managed, _board = _managed_scratch_path_info(p) + return is_managed def _cleanup_workspace(conn: sqlite3.Connection, task_id: str) -> None: @@ -6789,7 +7013,9 @@ def check_respawn_guard(conn: sqlite3.Connection, task_id: str) -> Optional[str] ``"recent_success"`` A completed run exists within ``_RESPAWN_GUARD_SUCCESS_WINDOW`` seconds. Useful work already succeeded for this task; wait for - human review rather than immediately re-spawning. + human review rather than immediately re-spawning. Bypassed when an + explicit re-queue event (status change, promote, unblock, reclaim) + arrives AFTER that completion — that's a deliberate re-run request. ``"active_pr"`` A GitHub PR URL appears in a recent task comment (within @@ -6852,13 +7078,29 @@ def check_respawn_guard(conn: sqlite3.Connection, task_id: str) -> Optional[str] return "blocker_auth" # 3. Completed run within guard window — proof of recent success. + # Exception: an explicit re-queue AFTER that success (an operator + # dragging done→ready, a dependency re-promotion, an unblock, a + # reclaim) is a deliberate "run it again" — honor it instead of + # deferring. Without this, a manual done→ready just sits there, + # silently held by the guard, until the window elapses. cutoff = now - _RESPAWN_GUARD_SUCCESS_WINDOW - if conn.execute( - "SELECT id FROM task_runs " - "WHERE task_id = ? AND outcome = 'completed' AND ended_at >= ?", + recent_completed = conn.execute( + "SELECT ended_at FROM task_runs " + "WHERE task_id = ? AND outcome = 'completed' AND ended_at >= ? " + "ORDER BY ended_at DESC LIMIT 1", (task_id, cutoff), - ).fetchone(): - return "recent_success" + ).fetchone() + if recent_completed: + completed_at = int(recent_completed["ended_at"] or 0) + requeued_after = conn.execute( + "SELECT 1 FROM task_events " + "WHERE task_id = ? AND created_at >= ? " + "AND kind IN ('status', 'promoted', 'unblocked', 'reclaimed') " + "LIMIT 1", + (task_id, completed_at), + ).fetchone() + if not requeued_after: + return "recent_success" # 4. GitHub PR URL in a recent comment — prior worker already opened a PR. pr_cutoff = now - _RESPAWN_GUARD_PR_WINDOW @@ -7768,9 +8010,18 @@ def _default_spawn( # attributed correctly regardless of how the child loads config. env["HERMES_PROFILE"] = profile_arg + # A worker must NEVER boot the interactive TUI: an inherited HERMES_TUI=1 + # or a `display.interface: tui` in the profile's config would send the + # quiet chat run into the Ink TUI, whose no-TTY bail-out exits 0 without + # doing the task → "protocol violation" on every attempt. `--cli` is the + # highest-precedence interface override; dropping the env var covers + # older hermes builds on PATH that predate the flag's precedence. + env.pop("HERMES_TUI", None) + cmd = [ *_resolve_hermes_argv(), "-p", profile_arg, + "--cli", # Worker subprocesses switch to a profile-scoped HERMES_HOME above, # so they see that profile's shell-hook allowlist instead of the # dispatcher's root allowlist. Pass --accept-hooks explicitly so @@ -7795,6 +8046,13 @@ def _default_spawn( "chat", "-q", prompt, ]) + if task.goal_mode: + # Goal-mode workers must take the fully-quiet single-query path: + # the kanban goal-loop hook (_run_kanban_goal_loop_q) only runs in + # cli.py's quiet branch. Without -Q the worker gets exactly one + # turn, prints text, exits rc=0, and the dispatcher records a + # protocol violation (incident 2026-06-09 t_d9cbe312). + cmd.append("-Q") # Redirect output to a per-task log under <board-root>/logs/. # Anchored at the board root (not the shared kanban root), so # `hermes kanban log` on a specific board reads its own file and diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 81da4c19156..8173f1e3338 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -530,7 +530,20 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: Accepts the legacy ``spawn_failure_threshold`` config key for back-compat. + + Terminal statuses are exempt: a done/archived card has nothing left + to retry, so a lingering failure streak is history, not a signal. + (``complete_task`` resets the counter, but a manual done — e.g. a + dashboard drag — ends no run and used to leave the flag stuck.) + + A fresh attempt in flight (``running``) is also exempt: retrying a + task should clear the stale failure banner until this attempt also + resolves. Otherwise a card that's actively trying again still shows + "failed Nx", which reads as a current failure. It re-fires if the new + run fails too (status leaves ``running`` with a recorded outcome). """ + if _task_field(task, "status") in ("done", "archived", "running"): + return [] threshold = _positive_int(cfg.get( "failure_threshold", cfg.get("spawn_failure_threshold", 3), @@ -649,7 +662,20 @@ def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]: total failures) so the operator gets a crash-specific heads-up before the unified rule kicks in. Suppresses itself when the unified rule is also about to fire, to avoid double-flagging. + + Terminal statuses are exempt for the same reason as + ``repeated_failures`` — with one extra wrinkle: this rule reads run + history, and a manual done (dashboard drag) appends no ``completed`` + run to break the crash streak, so the flag was permanent (#kanban + desktop dogfood). Done means done. + + ``running`` is exempt too: a fresh attempt is in flight, and its + in-flight run (no outcome yet) doesn't break the trailing crash scan, + so a retried card kept showing "crashed Nx" over an active run. The + banner re-fires if the new attempt also crashes. """ + if _task_field(task, "status") in ("done", "archived", "running"): + return [] failure_threshold = int(cfg.get( "failure_threshold", cfg.get("spawn_failure_threshold", 3), diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f21388c2181..f10fef3fd95 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -148,7 +148,17 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool: """Earliest TUI decision, usable before argparse/config imports. Precedence: explicit ``--cli`` wins (forces classic REPL), then - ``--tui``/``HERMES_TUI=1``, then ``display.interface`` in config. + explicit ``--tui``/``HERMES_TUI=1``, then a real-TTY gate (a + non-interactive stdio can't host the Ink UI, so ambient config never + boots it there), then ``display.interface`` in config. + + The TTY gate is load-bearing for headless spawners — kanban workers, + cron jobs, pipes run ``hermes … chat -q`` with stdio on a pipe. This + is the earliest launch decision (it runs before ``cmd_chat`` / + ``_resolve_use_tui``), so a ``display.interface: tui`` default used to + boot the TUI here — whose no-TTY bail-out exits 0 without doing the + task → "protocol violation" on every attempt. An explicit ``--tui`` + still reaches the informative bail-out. """ if argv is None: argv = sys.argv[1:] @@ -156,6 +166,11 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool: return False if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv: return True + try: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return False + except Exception: + return False return _config_default_interface_early() == "tui" @@ -2193,16 +2208,34 @@ def _resolve_use_tui(args) -> bool: Precedence (highest first): 1. ``--cli`` flag → always classic REPL - 2. ``--tui`` flag / ``HERMES_TUI=1`` → always TUI - 3. ``display.interface`` config value ("cli" | "tui") - 4. default → classic REPL + 2. ``--tui`` flag → always TUI (explicit ask) + 3. no TTY → always classic (ambient prefs don't apply) + 4. ``HERMES_TUI=1`` env → TUI + 5. ``display.interface`` config value ("cli" | "tui") + 6. default → classic REPL Explicit flags always win over config so muscle memory and scripts keep working regardless of the configured default. + + The TTY gate (3) is load-bearing: ambient TUI preferences (env var or + config default) must never hijack a NON-interactive invocation. Kanban + workers, cron jobs, and pipelines run ``hermes … chat -q`` with stdout + on a pipe; booting the Ink TUI there hits its no-TTY bail-out, which + prints a resume hint and exits 0 — a kanban worker then dies with + "exited cleanly without calling kanban_complete — protocol violation" + on every attempt (found dogfooding the desktop kanban board). A user + who *explicitly* passes ``--tui`` still gets the informative bail-out. """ if getattr(args, "cli", False): return False - if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1": + if getattr(args, "tui", False): + return True + try: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return False + except Exception: + return False + if os.environ.get("HERMES_TUI") == "1": return True try: from hermes_cli.config import load_config @@ -2253,6 +2286,27 @@ def cmd_chat(args): # If resolution fails, keep the original value — _init_agent will # report "Session not found" with the original input + # Session<->workspace binding: cd back into a resumed session's recorded cwd + # so it resumes in the repo it belonged to. Opt out with --no-restore-cwd; + # skipped under --worktree (that path owns its own dir). Best-effort — a + # missing dir warns and stays put rather than failing the resume. + if ( + getattr(args, "resume", None) + and not getattr(args, "no_restore_cwd", False) + and not getattr(args, "worktree", False) + ): + try: + from hermes_state import SessionDB + + _saved_cwd = ((SessionDB().get_session(args.resume) or {}).get("cwd") or "").strip() + if _saved_cwd and not os.path.isdir(_saved_cwd): + print(f"⚠ session's recorded dir is gone ({_saved_cwd}); staying in {os.getcwd()}") + elif _saved_cwd and os.path.realpath(_saved_cwd) != os.path.realpath(os.getcwd()): + os.chdir(_saved_cwd) + print(f"↪ restored workspace dir: {_saved_cwd}") + except Exception: + pass # never let cwd-restore break a resume + # xAI retirement warning — one-shot, non-blocking, never fails startup try: from hermes_cli.xai_retirement import ( @@ -2325,7 +2379,11 @@ def cmd_chat(args): except Exception: pass - # --yolo: bypass all dangerous command approvals + # --yolo: bypass all dangerous command approvals. + # Also set in main() before _prepare_agent_startup() — that is the + # authoritative site because it runs before tool imports freeze + # _YOLO_MODE_FROZEN. This redundant set is a safety net for callers + # that invoke cmd_chat directly (e.g. subcommand dispatch). if getattr(args, "yolo", False): os.environ["HERMES_YOLO_MODE"] = "1" @@ -3888,7 +3946,7 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""): str(effort).strip().lower() for effort in efforts if str(effort).strip() ) ) - canonical_order = ("minimal", "low", "medium", "high", "xhigh") + canonical_order = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra") ordered = [effort for effort in canonical_order if effort in deduped] ordered.extend(effort for effort in deduped if effort not in canonical_order) if not ordered: @@ -12080,6 +12138,23 @@ def cmd_dashboard(args): print(" Or drop --skip-build to build automatically.") sys.exit(1) print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}") + else: + # HERMES_WEB_DIST is set without --skip-build: the build is skipped + # (the env var points at a caller-managed dist), so validate it the + # same way the --skip-build branch does — otherwise the server starts + # and serves 404s with no obvious cause (same failure mode as #23817, + # via the env-var path). + _dist_root = Path(os.environ["HERMES_WEB_DIST"]).expanduser() + if not (_dist_root / "index.html").exists(): + print(f"✗ HERMES_WEB_DIST is set but no web dist found at: {_dist_root}") + print(" Pre-build first: npm install --workspace web && npm run build -w web") + print(" Or unset HERMES_WEB_DIST to build and use the default web UI dist.") + sys.exit(1) + # Write the expanded path back: web_server reads HERMES_WEB_DIST raw + # at import (no expanduser), so a validated "~/dist" would otherwise + # pass here and still 404 there. + os.environ["HERMES_WEB_DIST"] = str(_dist_root) + print(f"→ Using web dist from HERMES_WEB_DIST: {_dist_root}") # Discover and load plugins so any DashboardAuthProvider plugin # (e.g. plugins/dashboard_auth/nous) registers BEFORE start_server's @@ -12360,6 +12435,15 @@ def _should_background_mcp_startup(args) -> bool: def _prepare_agent_startup(args) -> None: """Discover plugins/MCP/hooks for commands that can run an agent turn.""" + # --yolo: chokepoint guarantee that HERMES_YOLO_MODE is set before ANY + # plugin/tool discovery below imports tools.approval, which freezes + # _YOLO_MODE_FROZEN at import time (PR #7994 security design). main()'s + # dispatch path also sets this earlier, but _prepare_agent_startup() is + # reachable from other launchers too (e.g. the Termux fast-CLI path), + # so the guarantee lives here where the import is actually triggered + # (#60328). + if getattr(args, "yolo", False): + os.environ["HERMES_YOLO_MODE"] = "1" _apply_safe_mode(args) _sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None)) @@ -13451,6 +13535,12 @@ def main(): sessions_list.add_argument( "--limit", type=int, default=20, help="Max sessions to show" ) + sessions_list.add_argument( + "--workspace", + metavar="NEEDLE", + help="Only sessions in one workspace: a git repo root or project dir " + "(matched by path substring or basename).", + ) def _add_session_filter_args(p, default_older_help): p.add_argument( @@ -13777,13 +13867,58 @@ def main(): _exclude = None if _source else ["tool"] if action == "list": + from hermes_state import workspace_key as _ws_key + sessions = db.list_sessions_rich( source=args.source, exclude_sources=_exclude, limit=args.limit ) + + # Workspace filter: match a session by its workspace key (git repo + # root, else cwd) — path substring or exact basename. + _ws_filter = (getattr(args, "workspace", None) or "").strip() + if _ws_filter: + _needle = _ws_filter.lower() + + def _in_workspace(s): + key = (_ws_key(s) or "").lower() + return bool(key) and ( + _needle in key or _needle == os.path.basename(key.rstrip("/\\")) + ) + + sessions = [s for s in sessions if _in_workspace(s)] + if not sessions: print("No sessions found.") return + + # Short workspace label: the repo/dir basename, "—" when unbound. The + # Workspace column only appears once at least one session carries one + # (or when filtering), so all-unbound listings read as before. + def _ws_label(s): + key = _ws_key(s) + return (os.path.basename(key.rstrip("/\\")) or key) if key else "—" + + has_ws = bool(_ws_filter) or any(_ws_key(s) for s in sessions) has_titles = any(s.get("title") for s in sessions) + + if has_ws: + if has_titles: + print(f"{'Title':<28} {'Workspace':<18} {'Last Active':<13} {'ID'}") + print("─" * 110) + else: + print(f"{'Preview':<38} {'Workspace':<18} {'Last Active':<13} {'Src':<6} {'ID'}") + print("─" * 100) + for s in sessions: + last_active = _relative_time(s.get("last_active")) + ws = _ws_label(s)[:16] + if has_titles: + title = (s.get("title") or "—")[:26] + print(f"{title:<28} {ws:<18} {last_active:<13} {s['id']}") + else: + preview = s.get("preview", "")[:36] + print(f"{preview:<38} {ws:<18} {last_active:<13} {s['source']:<6} {s['id']}") + return + if has_titles: print(f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") print("─" * 110) @@ -14577,6 +14712,15 @@ def main(): cmd_version(args) return + # --yolo: set HERMES_YOLO_MODE *before* plugin discovery. The call to + # _prepare_agent_startup() below triggers discover_plugins() → tool + # imports, and tools.approval freezes _YOLO_MODE_FROZEN at module + # import time (PR #7994, security hardening against prompt-injection). + # If the env var is set only later (e.g. inside cmd_chat), the frozen + # value is already False and --yolo silently does nothing. + if getattr(args, "yolo", False): + os.environ["HERMES_YOLO_MODE"] = "1" + # Discover Python plugins and register shell hooks once, before any # command that can fire lifecycle hooks. Both are idempotent; gated # so introspection/management commands (hermes hooks list, cron diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 61522d6c400..75a1ba10be8 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -548,7 +548,12 @@ def _model_sort_key(model_id: str, prefix: str) -> tuple: # Suffix quality ranking: pro/max > (no suffix) > omni/flash/mini/lite # Lower number = preferred - _SUFFIX_RANK = {"pro": 0, "max": 0, "plus": 0, "turbo": 0} + # "sol" is the flagship tier of the GPT-5.6 series (sol > terra > luna); + # without it, alias resolution would tiebreak alphabetically and pick + # luna (the cheapest) for `/model gpt`. Unlike pro/max/plus/turbo it is a + # series codename, not a generic quality word — revisit if another vendor + # ever ships a "-sol" suffix that isn't a flagship. + _SUFFIX_RANK = {"pro": 0, "max": 0, "plus": 0, "turbo": 0, "sol": 0} suffix_rank = _SUFFIX_RANK.get(suffix, 1) return version_key + (suffix_rank, suffix) @@ -1389,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 {} @@ -1693,6 +1717,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: @@ -1706,8 +1736,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: @@ -1722,6 +1757,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 @@ -1796,9 +1840,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) @@ -1942,9 +1984,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 @@ -2228,6 +2268,7 @@ def list_authenticated_providers( "api_url": api_url, "api_key": api_key, "models": [], + "has_explicit_models": False, "discover_models": discover, "extra_headers": entry_extra_headers, } @@ -2250,7 +2291,10 @@ def list_authenticated_providers( if default_model and default_model not in groups[group_key]["models"]: groups[group_key]["models"].append(default_model) - for model_id in _declared_model_ids(entry.get("models", {})): + declared_models = _declared_model_ids(entry.get("models", {})) + if declared_models: + groups[group_key]["has_explicit_models"] = True + for model_id in declared_models: if model_id not in groups[group_key]["models"]: groups[group_key]["models"].append(model_id) @@ -2313,11 +2357,13 @@ def list_authenticated_providers( # the (possibly partial) ``models:`` subset configured for # context-length overrides with the full live catalog. # This is the Bifrost / aggregator-gateway case. - # - Without an api_key but with an explicit ``models:`` list - # (or top-level ``model:``), the user is narrowing a public - # endpoint to a specific subset (e.g. ollama.com /v1/models - # returns 35 models but the user only wants 4). Preserve the - # explicit list and skip live discovery. + # - Without an api_key but with an explicit ``models:`` list, + # the user is narrowing a public endpoint to a specific subset + # (e.g. ollama.com /v1/models returns 35 models but the user + # only wants 4). Preserve the explicit list and skip live + # discovery. The singular ``model:`` field is only the current + # active selection and must not suppress discovery on local + # no-key endpoints. # - Without an api_key AND no explicit models, fall through to # live discovery so bare-endpoint custom providers (local # llama.cpp / Ollama servers) still appear populated. @@ -2335,7 +2381,7 @@ def list_authenticated_providers( should_probe = ( _can_probe_custom_provider(row_is_current=_grp_is_current) and bool(api_url) - and (bool(api_key) or not grp["models"]) + and (bool(api_key) or not grp.get("has_explicit_models")) and grp.get("discover_models", True) ) if should_probe: diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 6090154ae9d..8f3a5c46d4f 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -9,6 +9,7 @@ from __future__ import annotations import json import os +import re import urllib.parse import urllib.request import urllib.error @@ -18,6 +19,7 @@ from pathlib import Path from typing import Any, NamedTuple, Optional from hermes_cli import __version__ as _HERMES_VERSION +from hermes_cli.urllib_security import open_credentialed_url # Identify ourselves so endpoints fronted by Cloudflare's Browser Integrity # Check (error 1010) don't reject the default ``Python-urllib/*`` signature. @@ -29,6 +31,10 @@ COPILOT_EDITOR_VERSION = "vscode/1.104.1" COPILOT_REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"] COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"] +def _urlopen_model_catalog_request(req: urllib.request.Request, *, timeout: float): + """Open catalog requests without forwarding headers across origins.""" + return open_credentialed_url(req, timeout=timeout) + # Fallback OpenRouter snapshot used when the live catalog is unavailable. # (model_id, display description shown in menus) @@ -40,6 +46,12 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("anthropic/claude-sonnet-5", ""), ("anthropic/claude-haiku-4.5", ""), # OpenAI + ("openai/gpt-5.6-sol", ""), + ("openai/gpt-5.6-sol-pro", ""), + ("openai/gpt-5.6-terra", ""), + ("openai/gpt-5.6-terra-pro", ""), + ("openai/gpt-5.6-luna", ""), + ("openai/gpt-5.6-luna-pro", ""), ("openai/gpt-5.5", ""), ("openai/gpt-5.5-pro", ""), ("openai/gpt-5.4-mini", ""), @@ -49,7 +61,6 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("google/gemini-3.5-flash", ""), # xAI ("x-ai/grok-4.5", ""), - ("x-ai/grok-4.3", ""), # DeepSeek ("deepseek/deepseek-v4-pro", ""), ("deepseek/deepseek-v4-flash", ""), @@ -186,6 +197,12 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "anthropic/claude-sonnet-5", "anthropic/claude-haiku-4.5", # OpenAI + "openai/gpt-5.6-sol", + "openai/gpt-5.6-sol-pro", + "openai/gpt-5.6-terra", + "openai/gpt-5.6-terra-pro", + "openai/gpt-5.6-luna", + "openai/gpt-5.6-luna-pro", "openai/gpt-5.5", "openai/gpt-5.5-pro", "openai/gpt-5.4-mini", @@ -195,7 +212,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "google/gemini-3.5-flash", # xAI "x-ai/grok-4.5", - "x-ai/grok-4.3", # DeepSeek "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", @@ -235,6 +251,12 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "gpt-4o-mini", ], "openai-api": [ + "gpt-5.6-sol", + "gpt-5.6-sol-pro", + "gpt-5.6-terra", + "gpt-5.6-terra-pro", + "gpt-5.6-luna", + "gpt-5.6-luna-pro", "gpt-5.5", "gpt-5.5-pro", "gpt-5.4", @@ -421,7 +443,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "minimax-m3", "minimax-m2.7", "minimax-m2.5", - "minimax-m3-free", "glm-5.2", "glm-5.1", "glm-5", @@ -431,7 +452,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "deepseek-v4-flash-free", "qwen3.7-plus", "qwen3.6-plus", - "qwen3.6-plus-free", "qwen3.5-plus", "grok-build-0.1", "big-pickle", @@ -907,7 +927,7 @@ def fetch_nous_recommended_models( url, headers={"Accept": "application/json"}, ) - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) if not isinstance(data, dict): data = {} @@ -1062,6 +1082,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("ollama-cloud", "Ollama Cloud", "Ollama Cloud (Cloud-hosted open models, ollama.com)"), ProviderEntry("arcee", "Arcee AI", "Arcee AI (Trinity models, direct API)"), ProviderEntry("gmi", "GMI Cloud", "GMI Cloud (Multi-model direct API)"), + ProviderEntry("fireworks", "Fireworks AI", "Fireworks AI (OpenAI-compatible direct model API)"), ProviderEntry("kilocode", "Kilo Code", "Kilo Code (Kilo Gateway API)"), ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (Curated models, pay-as-you-go)"), ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (Open models subscription)"), @@ -1225,6 +1246,8 @@ _PROVIDER_ALIASES = { "arceeai": "arcee", "gmi-cloud": "gmi", "gmicloud": "gmi", + "fireworks-ai": "fireworks", + "fw": "fireworks", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", "minimax-portal": "minimax-oauth", @@ -1385,7 +1408,7 @@ def fetch_openrouter_models( "https://openrouter.ai/api/v1/models", headers={"Accept": "application/json"}, ) - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: payload = json.loads(resp.read().decode()) except Exception: return list(_openrouter_catalog_cache or fallback) @@ -1506,7 +1529,7 @@ def fetch_models_with_pricing( try: req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: payload = json.loads(resp.read().decode()) except Exception: _pricing_cache[cache_key] = {} @@ -1572,6 +1595,8 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d ) if normalized == "novita": return _fetch_novita_pricing(force_refresh=force_refresh) + if normalized == "deepinfra": + return _fetch_deepinfra_pricing(force_refresh=force_refresh) if normalized == "nous": api_key, base_url = _resolve_nous_pricing_credentials() if base_url: @@ -1620,7 +1645,7 @@ def _fetch_novita_pricing( try: req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: payload = json.loads(resp.read().decode()) except Exception: _pricing_cache[cache_key] = {} @@ -2364,6 +2389,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) merged_lower.add(m.lower()) return merged return list(_PROVIDER_MODELS.get("anthropic", [])) + if normalized == "deepinfra": + # DeepInfra's generic /models endpoint mixes chat, image, video, + # speech, and embedding models. The tagged catalog helper is the only + # safe source for the chat picker, including its empty/failure result. + return _fetch_deepinfra_models(force_refresh=force_refresh) or [] if normalized == "ollama-cloud": live = fetch_ollama_cloud_models(force_refresh=force_refresh) if live: @@ -2484,7 +2514,15 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) # live API is the authoritative catalog, so they merge # live-first — live entries lead and stale curated entries # no longer pollute the top of the picker. (#49129) - curated = list(_PROVIDER_MODELS.get(normalized, [])) + # + # Plugin providers with no static _PROVIDER_MODELS entry fall + # back to the profile's curated fallback_models so their + # agentic picks lead the picker instead of whatever the live + # catalog happens to return first (e.g. Fireworks lists an + # image model, flux-*, ahead of its chat models). + curated = list(_PROVIDER_MODELS.get(normalized, [])) or list( + _p.fallback_models or () + ) if curated: if normalized in _LIVE_FIRST_PICKER_PROVIDERS: primary, secondary = live, curated @@ -2752,7 +2790,7 @@ def _fetch_anthropic_models( _anthropic_models_url(base_url), headers=h, ) - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: return json.loads(resp.read().decode()) try: @@ -2867,7 +2905,7 @@ def fetch_github_model_catalog( for headers in attempts: req = urllib.request.Request(COPILOT_MODELS_URL, headers=headers) try: - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) items = _payload_items(data) models: list[dict[str, Any]] = [] @@ -2984,7 +3022,7 @@ def _lmstudio_fetch_raw_models( headers = _lmstudio_request_headers(api_key) request = urllib.request.Request(server_root + "/api/v1/models", headers=headers) try: - with urllib.request.urlopen(request, timeout=timeout) as resp: + with _urlopen_model_catalog_request(request, timeout=timeout) as resp: payload = json.loads(resp.read().decode()) except urllib.error.HTTPError as exc: if exc.code in {401, 403}: @@ -3121,15 +3159,13 @@ def ensure_lmstudio_model_loaded( load_headers = dict(headers) load_headers["Content-Type"] = "application/json" try: - with urllib.request.urlopen( - urllib.request.Request( - server_root + "/api/v1/models/load", - data=body, - headers=load_headers, - method="POST", - ), - timeout=timeout, - ) as resp: + load_request = urllib.request.Request( + server_root + "/api/v1/models/load", + data=body, + headers=load_headers, + method="POST", + ) + with _urlopen_model_catalog_request(load_request, timeout=timeout) as resp: resp.read() except Exception: return None @@ -3978,7 +4014,7 @@ def probe_api_models( tried.append(url) req = urllib.request.Request(url, headers=headers) try: - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) return { "models": [m.get("id", "") for m in data.get("data", [])], @@ -3999,6 +4035,227 @@ def probe_api_models( } +# Legacy filter — used when an item has no surface tag (rolling out +# 2026-05). Once every model returned by the catalog endpoint carries an +# explicit surface tag (``chat``/``embed``/``image-gen``/``tts``/``stt``) +# the regex path becomes unreachable and can be removed. +_DEEPINFRA_EXCLUDE_RE = re.compile( + r"(?i)(embed|rerank|whisper|stable-diffusion|flux|sdxl|" + r"tts|bark|speech|image-gen|clip|vit-|dpt-)", +) + +# Surface tags announce *what kind of model* this is. When none of these +# are present on a catalog entry, the tags array only carries capability +# tags (``reasoning``, ``vision``, ``prompt_cache``, …) and we have to +# fall back to id-regex inference for the chat surface. +_DEEPINFRA_SURFACE_TAGS: frozenset[str] = frozenset({ + "chat", "embed", "image-gen", "tts", "stt", "video-gen", +}) + +_DEEPINFRA_DEFAULT_BASE_URL = "https://api.deepinfra.com/v1/openai" +_DEEPINFRA_MODELS_QUERY = "filter=true&sort_by=hermes" + +# Module-level cache for the full tagged catalog response, keyed by base URL. +# Each value is the parsed ``data`` list. Surface-specific filters read from +# this cache so a single network round-trip serves chat / image-gen / tts / +# stt callers across the whole process lifetime. +_deepinfra_catalog_cache: dict[str, list[dict]] = {} + +# Negative cache: monotonic timestamp of the last failed fetch, keyed by base +# URL. Without this, an unreachable catalog (offline / DNS / firewall) makes +# every surface helper (chat picker, pricing, image/video/tts/stt defaults, +# vision) re-attempt a fresh blocking fetch that eats the full timeout each +# time — several sequential stalls in one user-visible operation. A short TTL +# lets connectivity recover without a process restart. +_deepinfra_catalog_neg_cache: dict[str, float] = {} +_DEEPINFRA_CATALOG_NEG_TTL = 60.0 # seconds + + +def _deepinfra_catalog_url() -> tuple[str, str]: + """Return ``(cache_key, full_url)`` for the DeepInfra catalog endpoint.""" + base = os.getenv("DEEPINFRA_BASE_URL", "").strip() or _DEEPINFRA_DEFAULT_BASE_URL + cache_key = base.rstrip("/") + return cache_key, f"{cache_key}/models?{_DEEPINFRA_MODELS_QUERY}" + + +def _fetch_deepinfra_catalog( + *, + timeout: float = 5.0, + force_refresh: bool = False, +) -> Optional[list[dict]]: + """Fetch the raw DeepInfra catalog list with module-level caching. + + The endpoint serves chat + embed + image-gen + tts + stt models in one + response. Authentication is optional but Bearer-attached when available + so user-scoped catalogs (private fine-tunes etc.) are visible. + """ + cache_key, url = _deepinfra_catalog_url() + if not force_refresh: + if cache_key in _deepinfra_catalog_cache: + return _deepinfra_catalog_cache[cache_key] + last_fail = _deepinfra_catalog_neg_cache.get(cache_key) + if last_fail is not None and (time.monotonic() - last_fail) < _DEEPINFRA_CATALOG_NEG_TTL: + return None + + headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT} + api_key = os.getenv("DEEPINFRA_API_KEY", "").strip() + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + req = urllib.request.Request(url, headers=headers) + try: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: + payload = json.loads(resp.read().decode()) + except Exception: + _deepinfra_catalog_neg_cache[cache_key] = time.monotonic() + return None + + data = payload.get("data") + if not isinstance(data, list): + _deepinfra_catalog_neg_cache[cache_key] = time.monotonic() + return None + + _deepinfra_catalog_cache[cache_key] = data + _deepinfra_catalog_neg_cache.pop(cache_key, None) + return data + + +def _fetch_deepinfra_models_by_tag( + tag: str, + *, + timeout: float = 5.0, + force_refresh: bool = False, +) -> Optional[list[dict]]: + """Return DeepInfra models whose ``metadata.tags`` includes *tag*. + + Each returned item is ``{"id": str, "metadata": dict}`` so callers can + inspect context length, pricing, default dimensions (image-gen), + pricing units (tts ``input_characters``, stt ``input_seconds``), etc. + + For the chat surface, items without any ``tags`` field fall through + to the legacy name-regex exclusion so this keeps working while the + tag rollout (mid-2026) is still in flight. + + Returns ``None`` on network failure. + """ + data = _fetch_deepinfra_catalog(timeout=timeout, force_refresh=force_refresh) + if data is None: + return None + + matched: list[dict] = [] + for item in data: + mid = item.get("id") + if not mid: + continue + # ``metadata is None`` means DeepInfra returns a stub without + # pricing/context — typically a model that's listed but not + # served. Skip those for every surface. + raw_metadata = item.get("metadata") + if raw_metadata is None: + continue + metadata = raw_metadata if isinstance(raw_metadata, dict) else {} + raw_tags = metadata.get("tags") + tags = raw_tags if isinstance(raw_tags, list) else [] + has_surface_tag = any(t in _DEEPINFRA_SURFACE_TAGS for t in tags) + + if has_surface_tag: + if tag in tags: + matched.append({"id": mid, "metadata": metadata}) + continue + # Surface-tag rollout incomplete — fall back to id-regex inference. + # Only meaningful for the chat surface; embed/image-gen/tts/stt + # cannot be safely inferred from an id alone. + if tag == "chat" and not _DEEPINFRA_EXCLUDE_RE.search(mid): + matched.append({"id": mid, "metadata": metadata}) + + return matched + + +def _fetch_deepinfra_models( + timeout: float = 5.0, + *, + force_refresh: bool = False, +) -> Optional[list[str]]: + """Return DeepInfra chat-model ids (tag-aware, regex fallback). + + Thin wrapper over :func:`_fetch_deepinfra_models_by_tag` so historical + callers in :func:`provider_model_ids` keep their string-list contract. + Returns ``None`` on network failure, an empty list if the catalog + contains no chat-tagged ids (which would itself be surprising). + """ + items = _fetch_deepinfra_models_by_tag( + "chat", timeout=timeout, force_refresh=force_refresh + ) + if items is None: + return None + return [item["id"] for item in items] or None + + +def deepinfra_model_ids(tag: str, *, force_refresh: bool = False) -> list[str]: + """Return DeepInfra model ids carrying surface *tag* (``[]`` on failure). + + Single source of truth for the per-surface model shims (TTS/STT/vision), + replacing the copy-pasted ``import _fetch_deepinfra_models_by_tag → fetch + → [item["id"] …]`` wrapper each of them used to carry. + """ + items = _fetch_deepinfra_models_by_tag(tag, force_refresh=force_refresh) + return [item["id"] for item in items] if items else [] + + +def deepinfra_base_url(section: Optional[dict] = None) -> str: + """Resolve the DeepInfra OpenAI-compatible base URL, normalized. + + Precedence: config-section ``base_url`` → ``DEEPINFRA_BASE_URL`` env → + default. Always stripped with any trailing slash removed. Single source + of truth for the base-URL chain the TTS/STT/image/video shims each used + to re-code (with subtly divergent normalization). + """ + candidate = section.get("base_url") if isinstance(section, dict) else None + value = candidate or os.getenv("DEEPINFRA_BASE_URL") or _DEEPINFRA_DEFAULT_BASE_URL + return str(value).strip().rstrip("/") + + +def _fetch_deepinfra_pricing( + timeout: float = 5.0, + *, + force_refresh: bool = False, +) -> dict[str, dict[str, str]]: + """Return picker-shape pricing for DeepInfra chat models. + + DeepInfra publishes ``input_tokens`` / ``output_tokens`` / + ``cache_read_tokens`` in $/MTok; the picker expects per-token strings + under ``prompt`` / ``completion`` / ``input_cache_read`` (mirrors the + OpenRouter shape consumed by + :func:`format_model_pricing_table`). Cached via the catalog helper so + repeated picker renders are free. + """ + items = _fetch_deepinfra_models_by_tag( + "chat", timeout=timeout, force_refresh=force_refresh + ) + if not items: + return {} + + result: dict[str, dict[str, str]] = {} + for item in items: + metadata = item.get("metadata") or {} + pricing = metadata.get("pricing") if isinstance(metadata, dict) else None + if not isinstance(pricing, dict): + continue + entry: dict[str, str] = {} + inp = pricing.get("input_tokens") + out = pricing.get("output_tokens") + cache_read = pricing.get("cache_read_tokens") + if inp is not None: + entry["prompt"] = str(float(inp) / 1_000_000) + if out is not None: + entry["completion"] = str(float(out) / 1_000_000) + if cache_read is not None: + entry["input_cache_read"] = str(float(cache_read) / 1_000_000) + if entry: + result[item["id"]] = entry + return result + + def fetch_api_models( api_key: Optional[str], base_url: Optional[str], diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index a4c6ca9cc25..e7db628a7c1 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -150,6 +150,12 @@ def _write_usage_file(path: Optional[str], result: dict, failure: Optional[str] "session_id": result.get("session_id"), "completed": result.get("completed"), "failed": bool(result.get("failed")) or failure is not None, + # Billing-audit field: the service tier this run REQUESTED via + # request_overrides.extra_body (e.g. OpenAI "flex"). None when + # unset. Lets batch pipelines verify the tier they think they're + # paying for actually went out on the wire (July 2026 incident: + # a config-matching bug silently dropped flex -> 2.3x billing). + "service_tier": result.get("service_tier"), } if failure is not None: report["failure"] = failure diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index ea0b8ea2ffe..6ca393fca53 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -172,17 +172,19 @@ VALID_HOOKS: Set[str] = { # Kwargs: event: MessageEvent, gateway: GatewayRunner, session_store. "pre_gateway_dispatch", # Approval lifecycle hooks. Fired by tools/approval.py when a dangerous - # command needs user approval -- fires BOTH for CLI-interactive prompts - # and for gateway/ACP approvals (Telegram, Discord, Slack, TUI, etc.). + # command needs an approval decision -- fires for CLI-interactive prompts, + # gateway/ACP approvals, and smart-mode auxiliary-LLM decisions. # Observers only: return values are ignored. Plugins cannot veto or # pre-answer an approval from these hooks (use pre_tool_call to block # a tool before it reaches approval). # # Kwargs for pre_approval_request: # command: str, description: str, pattern_key: str, pattern_keys: list[str], - # session_key: str, surface: "cli" | "gateway" + # session_key: str, surface: "cli" | "gateway" | "smart" # Kwargs for post_approval_response: same as above plus # choice: "once" | "session" | "always" | "deny" | "timeout" + # | "smart_approve" | "smart_deny" + # decided_by: "aux_llm" -- only on surface="smart" "pre_approval_request", "post_approval_response", # Kanban task lifecycle hooks. Fired by hermes_cli.kanban_db when a task diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 2ffe3bee6e5..257993e5bc8 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -196,6 +196,11 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { base_url_override="https://api.gmi-serving.com/v1", base_url_env_var="GMI_BASE_URL", ), + "fireworks": HermesOverlay( + transport="openai_chat", + extra_env_vars=("FIREWORKS_API_KEY",), + base_url_override="https://api.fireworks.ai/inference/v1", + ), "ollama-cloud": HermesOverlay( transport="openai_chat", base_url_override="https://ollama.com/v1", @@ -351,6 +356,10 @@ ALIASES: Dict[str, str] = { "gmi-cloud": "gmi", "gmicloud": "gmi", + # fireworks + "fireworks-ai": "fireworks", + "fw": "fireworks", + # Local server aliases → virtual "local" concept (resolved via user config) "lmstudio": "lmstudio", "lm-studio": "lmstudio", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index d5a69cea9f2..b40a97d8aa1 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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, @@ -1989,6 +2007,20 @@ def resolve_runtime_provider( pconfig = PROVIDER_REGISTRY.get(provider) if pconfig and pconfig.auth_type == "api_key": creds = resolve_api_key_provider_credentials(provider) + # An explicitly selected API-key provider is authoritative. Returning + # a runtime with an empty key defers failure until the first request and + # can make a later fallback look like a silent provider switch. Fail at + # resolution so callers surface the missing credential (or consult only + # an explicitly configured fallback chain). LM Studio's no-auth path + # supplies a non-empty placeholder in the credential resolver above. + if not has_usable_secret(creds.get("api_key")): + env_names = ", ".join(pconfig.api_key_env_vars) + hint = f" Set {env_names}." if env_names else "" + raise AuthError( + f"No usable credentials found for provider '{provider}'.{hint}", + provider=provider, + code="missing_api_key", + ) # Honour model.base_url from config.yaml when the configured provider # matches this provider — mirrors the Anthropic path above. Without # this, users who set model.base_url to e.g. api.minimaxi.com/anthropic diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 6b3821ed03e..24a5a2bcac0 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -8,7 +8,9 @@ Enhanced with UI-UX-PRO-MAX design intelligence. import json import datetime +import secrets from typing import Any, Dict, List +from urllib.parse import quote # --- Icons (Lucide-style SVGs) --- ICON_USER = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' @@ -26,6 +28,7 @@ HTML_TEMPLATE = """<!DOCTYPE html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-{script_nonce}'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'"> <title>{page_title} @@ -566,13 +569,13 @@ HTML_TEMPLATE = """ - ", + "arguments": "{}", + } + } + ], + } + ] + + html = _generate_messages_html(messages) + + # Raw, executable markup must never reach the standalone artifact. + assert "" not in html + # The escaped form must be present instead. + assert "<script>alert(1)</script>" in html + + +def test_role_is_escaped_in_html_export(): + messages = [ + { + "role": "", + "content": "hello", + "timestamp": 1700000000, + } + ] + + html = _generate_messages_html(messages) + + assert "" not in html + assert "<img src=x onerror=alert(document.domain)>" in html + # The class attribute must remain a single, well-formed token: a crafted + # role must not break out of it nor split into several unintended classes. + class_value = re.search(r'class="(message message-[^"]*active)"', html) + assert class_value is not None + assert " message-" in class_value.group(1) # exactly one message- class + assert class_value.group(1).count("message-") == 1 + + +def test_known_role_keeps_its_css_class(): + html = _generate_messages_html( + [{"role": "assistant", "content": "hi", "timestamp": 1700000000}] + ) + assert 'class="message message-assistant active"' in html diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index 2405b84a381..6d31d558f15 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -249,6 +249,46 @@ class TestListNavigation: assert allowlist[1] == {"name": "bob", "role": "admin"} +# --------------------------------------------------------------------------- +# String-typed config values — regression tests for #47515 +# --------------------------------------------------------------------------- + +class TestStringTypedConfigValues: + @pytest.mark.parametrize("value", ["off", "on", "yes", "no", "true", "false", "01"]) + def test_string_typed_values_are_not_coerced(self, _isolated_hermes_home, value): + """Values stay strings when DEFAULT_CONFIG declares the leaf as a string.""" + set_config_value("approvals.mode", value) + + import yaml + saved = yaml.safe_load(_read_config(_isolated_hermes_home)) + assert saved["approvals"]["mode"] == value + assert isinstance(saved["approvals"]["mode"], str) + + @pytest.mark.parametrize("key, value, expected", [ + ("terminal.persistent_shell", "off", False), + ("approvals.timeout", "30", 30), + ]) + def test_non_string_defaults_keep_existing_coercion( + self, _isolated_hermes_home, key, value, expected + ): + set_config_value(key, value) + + import yaml + saved = yaml.safe_load(_read_config(_isolated_hermes_home)) + node = saved + for part in key.split("."): + node = node[part] + assert node == expected + assert type(node) is type(expected) + + def test_unknown_keys_keep_existing_coercion(self, _isolated_hermes_home): + set_config_value("custom.enabled", "off") + + import yaml + saved = yaml.safe_load(_read_config(_isolated_hermes_home)) + assert saved["custom"]["enabled"] is False + + # --------------------------------------------------------------------------- # Secret redaction in display output (issue #50245) # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_setup_irc.py b/tests/hermes_cli/test_setup_irc.py index 31b263fec35..e7418507655 100644 --- a/tests/hermes_cli/test_setup_irc.py +++ b/tests/hermes_cli/test_setup_irc.py @@ -231,6 +231,18 @@ class TestIRCGatewaySetupFreshInstall: monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *a, **kw: False) monkeypatch.setattr(setup_mod, "prompt_choice", lambda *a, **kw: 0) + # Select ONLY the IRC row. Without this, the non-TTY checklist + # falls back to its cancel value (the pre-selected "configured" + # platforms) — on a dev machine with real platforms configured + # that runs their interactive setup_fn, which calls input() and + # dies under captured stdin. IRC's setup_fn is a no-op lambda. + monkeypatch.setattr( + setup_mod, + "prompt_checklist", + lambda title, items, pre=None: [ + i for i, item in enumerate(items) if "IRC" in item + ], + ) monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) monkeypatch.setattr(gateway_mod, "is_macos", lambda: False) monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False) diff --git a/tests/hermes_cli/test_skills_config.py b/tests/hermes_cli/test_skills_config.py index 8fbb063f620..31365fcf227 100644 --- a/tests/hermes_cli/test_skills_config.py +++ b/tests/hermes_cli/test_skills_config.py @@ -46,6 +46,34 @@ class TestGetDisabledSkills: from hermes_cli.skills_config import get_disabled_skills assert get_disabled_skills({"other": "value"}) == set() + def test_null_skills_section(self): + """``skills:`` with no value (YAML null) must not crash (#13026).""" + from hermes_cli.skills_config import get_disabled_skills + assert get_disabled_skills({"skills": None}) == set() + assert get_disabled_skills({"skills": None}, platform="telegram") == set() + + def test_null_disabled_key(self): + from hermes_cli.skills_config import get_disabled_skills + assert get_disabled_skills({"skills": {"disabled": None}}) == set() + + def test_scalar_disabled_is_single_skill_not_characters(self): + """``disabled: my-skill`` (bare scalar) is one skill name, not a + set of its characters (#13026).""" + from hermes_cli.skills_config import get_disabled_skills + assert get_disabled_skills({"skills": {"disabled": "my-skill"}}) == {"my-skill"} + + def test_scalar_platform_disabled(self): + from hermes_cli.skills_config import get_disabled_skills + config = {"skills": { + "disabled": ["global-skill"], + "platform_disabled": {"telegram": "tg-skill"}, + }} + assert get_disabled_skills(config, platform="telegram") == {"global-skill", "tg-skill"} + + def test_non_dict_skills_section(self): + from hermes_cli.skills_config import get_disabled_skills + assert get_disabled_skills({"skills": "oops"}) == set() + def test_empty_disabled_list(self): from hermes_cli.skills_config import get_disabled_skills assert get_disabled_skills({"skills": {"disabled": []}}) == set() diff --git a/tests/hermes_cli/test_urllib_security.py b/tests/hermes_cli/test_urllib_security.py new file mode 100644 index 00000000000..d21ac3bb030 --- /dev/null +++ b/tests/hermes_cli/test_urllib_security.py @@ -0,0 +1,561 @@ +"""Wire-level tests for credential-safe stdlib urllib redirects.""" + +from __future__ import annotations + +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +import urllib.error +import urllib.request + +import pytest + +from hermes_cli.urllib_security import ( + SafeCredentialRedirectHandler, + open_credentialed_url, + url_origin, +) + + +class _Response: + def __init__(self, payload: bytes = b"{}") -> None: + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self) -> bytes: + return self._payload + + +class _RecordingHandler(BaseHTTPRequestHandler): + redirect_to = "" + redirect_status = 302 + requests: list[tuple[str, dict[str, str]]] = [] + + def _record(self) -> None: + type(self).requests.append( + (self.command, {name.lower(): value for name, value in self.headers.items()}) + ) + + def do_GET(self): + if self.path.startswith("/redirect"): + self.send_response(type(self).redirect_status) + self.send_header("Location", type(self).redirect_to) + self.end_headers() + return + self._record() + body = json.dumps({"data": []}).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.path == "/redirect": + self.send_response(type(self).redirect_status) + self.send_header("Location", type(self).redirect_to) + self.end_headers() + return + self._record() + self.send_response(200) + self.end_headers() + + def log_message(self, _format, *_args): + pass + + +def _server(): + server = ThreadingHTTPServer(("127.0.0.1", 0), _RecordingHandler) + Thread(target=server.serve_forever, daemon=True).start() + return server + + +def _credential_headers() -> dict[str, str]: + return { + "Authorization": "Bearer secret", + "Cookie": "session=secret", + "CF-Access-Client-Secret": "cloudflare-secret", + "X-Custom-Auth": "tenant-secret", + "Accept": "application/json", + "User-Agent": "hermes-test", + } + + +def test_url_origin_normalizes_default_ports_and_trailing_dot(): + assert url_origin("https://EXAMPLE.test./models") == ( + "https", + "example.test", + 443, + ) + assert url_origin("https://example.test:443/other") == ( + "https", + "example.test", + 443, + ) + assert url_origin("http://example.test") != url_origin("https://example.test") + assert url_origin("https://example.test:0") == ( + "https", + "example.test", + 0, + ) + with pytest.raises(ValueError): + url_origin("https://example.test:not-a-port") + + +def test_cross_host_redirect_drops_arbitrary_credentials_on_wire(): + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + try: + request = urllib.request.Request( + f"http://127.0.0.1:{source.server_port}/redirect", + headers=_credential_headers(), + ) + with open_credentialed_url(request, timeout=3) as response: + response.read() + finally: + source.shutdown() + sink.shutdown() + + method, headers = _RecordingHandler.requests[-1] + assert method == "GET" + assert headers["accept"] == "application/json" + assert headers["user-agent"] == "hermes-test" + for name in ( + "authorization", + "cookie", + "cf-access-client-secret", + "x-custom-auth", + ): + assert name not in headers + + +def test_same_host_different_port_drops_credentials_on_wire(): + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://127.0.0.1:{sink.server_port}/sink" + try: + request = urllib.request.Request( + f"http://127.0.0.1:{source.server_port}/redirect", + headers=_credential_headers(), + ) + with open_credentialed_url(request, timeout=3) as response: + response.read() + finally: + source.shutdown() + sink.shutdown() + + _, headers = _RecordingHandler.requests[-1] + assert "authorization" not in headers + assert "cf-access-client-secret" not in headers + + +def test_same_origin_redirect_preserves_headers_on_wire(): + server = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://127.0.0.1:{server.server_port}/sink" + try: + request = urllib.request.Request( + f"http://127.0.0.1:{server.server_port}/redirect", + headers=_credential_headers(), + ) + with open_credentialed_url(request, timeout=3) as response: + response.read() + finally: + server.shutdown() + + _, headers = _RecordingHandler.requests[-1] + assert headers["authorization"] == "Bearer secret" + assert headers["cf-access-client-secret"] == "cloudflare-secret" + + +def test_scheme_downgrade_is_cross_origin(): + request = urllib.request.Request( + "https://models.example.test/models", headers=_credential_headers() + ) + handler = SafeCredentialRedirectHandler(request.full_url) + redirected = handler.redirect_request( + request, + None, + 302, + "Found", + {}, + "http://models.example.test/models", + ) + assert redirected is not None + headers = {name.lower(): value for name, value in redirected.header_items()} + assert "authorization" not in headers + assert "cf-access-client-secret" not in headers + + +def test_post_302_uses_urllib_semantics_and_drops_credentials(): + request = urllib.request.Request( + "https://models.example.test/load", + data=b"{}", + headers={**_credential_headers(), "Content-Type": "application/json"}, + method="POST", + ) + handler = SafeCredentialRedirectHandler(request.full_url) + redirected = handler.redirect_request( + request, + None, + 302, + "Found", + {}, + "https://other.example.test/load", + ) + assert redirected is not None + assert redirected.get_method() == "GET" + assert redirected.data is None + headers = {name.lower(): value for name, value in redirected.header_items()} + assert "authorization" not in headers + assert "content-type" not in headers + + +def test_post_307_remains_rejected_by_urllib(): + request = urllib.request.Request( + "https://models.example.test/load", + data=b"{}", + headers=_credential_headers(), + method="POST", + ) + handler = SafeCredentialRedirectHandler(request.full_url) + with pytest.raises(urllib.error.HTTPError): + handler.redirect_request( + request, + None, + 307, + "Temporary Redirect", + {}, + "https://other.example.test/load", + ) + + +def test_explicit_opener_factory_is_instrumentable_without_security_bypass(): + calls = [] + + class _Opener: + def open(self, request, *, timeout): + calls.append((request.full_url, timeout)) + return _Response() + + def factory(*handlers): + assert any(isinstance(h, SafeCredentialRedirectHandler) for h in handlers) + return _Opener() + + request = urllib.request.Request( + "https://models.example.test/models", headers={"Authorization": "secret"} + ) + with open_credentialed_url(request, timeout=7, opener_factory=factory): + pass + assert calls == [("https://models.example.test/models", 7)] + + +def test_installed_custom_opener_policy_is_preserved(monkeypatch): + opened = [] + + class FooHandler(urllib.request.BaseHandler): + def foo_open(self, request): + opened.append(request.full_url) + return _Response(b"custom") + + installed = urllib.request.build_opener(FooHandler()) + installed.addheaders = [ + ("X-Trace-Policy", "installed"), + ("User-agent", "enterprise-client"), + ] + monkeypatch.setattr(urllib.request, "_opener", installed) + + from hermes_cli.urllib_security import _secure_opener_from_installed_policy + + secured = _secure_opener_from_installed_policy( + "foo://models.example.test/catalog" + ) + assert secured.addheaders == [] + assert getattr(secured, "_hermes_initial_addheaders") == installed.addheaders + + request = urllib.request.Request( + "foo://models.example.test/catalog", headers={"Authorization": "secret"} + ) + with open_credentialed_url(request, timeout=3) as response: + assert response.read() == b"custom" + request_headers = { + name.lower(): value for name, value in request.header_items() + } + assert request_headers["x-trace-policy"] == "installed" + assert request_headers["user-agent"] == "enterprise-client" + assert opened == ["foo://models.example.test/catalog"] + + +def test_installed_proxy_handler_is_preserved(monkeypatch): + installed = urllib.request.build_opener( + urllib.request.ProxyHandler({"https": "http://proxy.example.test:8443"}) + ) + monkeypatch.setattr(urllib.request, "_opener", installed) + + from hermes_cli.urllib_security import _secure_opener_from_installed_policy + + secured = _secure_opener_from_installed_policy( + "https://models.example.test/catalog" + ) + proxy_handlers = [ + handler + for handler in getattr(secured, "handlers", ()) + if isinstance(handler, urllib.request.ProxyHandler) + ] + assert proxy_handlers + assert getattr(proxy_handlers[0], "proxies", {}) == { + "https": "http://proxy.example.test:8443" + } + + +def test_installed_request_processor_cannot_resurrect_cross_origin_secret( + monkeypatch, +): + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + + class SecretProcessor(urllib.request.BaseHandler): + handler_order = float("inf") # type: ignore[assignment] + + def http_request(self, request): + request.add_header("X-Installed-Secret", "must-not-cross") + return request + + installed = urllib.request.build_opener(SecretProcessor()) + installed.addheaders = [("X-Opener-Secret", "also-must-not-cross")] + monkeypatch.setattr(urllib.request, "_opener", installed) + try: + request = urllib.request.Request( + f"http://127.0.0.1:{source.server_port}/redirect", + headers={"Authorization": "Bearer secret"}, + ) + with open_credentialed_url(request, timeout=3) as response: + response.read() + finally: + source.shutdown() + sink.shutdown() + + _, headers = _RecordingHandler.requests[-1] + assert "authorization" not in headers + assert "x-installed-secret" not in headers + assert "x-opener-secret" not in headers + + +def test_multihop_redirects_never_resurrect_credentials(): + request = urllib.request.Request( + "https://a.example.test/models", headers=_credential_headers() + ) + handler = SafeCredentialRedirectHandler(request.full_url) + + same_origin = handler.redirect_request( + request, + None, + 302, + "Found", + {}, + "https://a.example.test/step-two", + ) + assert same_origin is not None + same_headers = { + name.lower(): value for name, value in same_origin.header_items() + } + assert "authorization" in same_headers + + cross_origin = handler.redirect_request( + same_origin, + None, + 302, + "Found", + {}, + "https://b.example.test/step-three", + ) + assert cross_origin is not None + cross_headers = { + name.lower(): value for name, value in cross_origin.header_items() + } + assert "authorization" not in cross_headers + assert "cf-access-client-secret" not in cross_headers + + returned = handler.redirect_request( + cross_origin, + None, + 302, + "Found", + {}, + "https://a.example.test/final", + ) + assert returned is not None + returned_headers = { + name.lower(): value for name, value in returned.header_items() + } + assert "authorization" not in returned_headers + assert "cf-access-client-secret" not in returned_headers + + +def test_probe_api_models_drops_custom_credentials_on_wire(): + from hermes_cli.models import probe_api_models + + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + try: + result = probe_api_models( + "provider-key", + f"http://127.0.0.1:{source.server_port}/redirect/..", + timeout=3, + request_headers={ + "CF-Access-Client-Secret": "cloudflare-secret", + "X-Custom-Auth": "tenant-secret", + }, + ) + finally: + source.shutdown() + sink.shutdown() + + assert result["models"] == [] + _, headers = _RecordingHandler.requests[-1] + assert "authorization" not in headers + assert "cf-access-client-secret" not in headers + assert "x-custom-auth" not in headers + + +class _LmStudioSourceHandler(BaseHTTPRequestHandler): + redirect_to = "" + + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + self.send_response(302) + self.send_header("Location", type(self).redirect_to) + self.end_headers() + + def log_message(self, format, *_args): + pass + + +def test_anthropic_profile_drops_x_api_key_on_redirect(monkeypatch): + import importlib + + AnthropicProfile = importlib.import_module( + "plugins.model-providers.anthropic" + ).AnthropicProfile + + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + + original_request = urllib.request.Request + + def local_anthropic_request(url, *args, **kwargs): + if url == "https://api.anthropic.com/v1/models": + url = f"http://127.0.0.1:{source.server_port}/redirect" + return original_request(url, *args, **kwargs) + + monkeypatch.setattr(urllib.request, "Request", local_anthropic_request) + try: + result = AnthropicProfile(name="anthropic").fetch_models( + api_key="anthropic-secret", timeout=3 + ) + finally: + source.shutdown() + sink.shutdown() + + assert result == [] + _, headers = _RecordingHandler.requests[-1] + assert "x-api-key" not in headers + assert headers["accept"] == "application/json" + + +def test_azure_catalog_probe_drops_api_key_and_bearer_on_redirect(): + from hermes_cli import azure_detect + + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + try: + status, body = azure_detect._http_get_json( + f"http://127.0.0.1:{source.server_port}/redirect", "azure-secret", timeout=3 + ) + finally: + source.shutdown() + sink.shutdown() + + assert status == 200 + assert body == {"data": []} + _, headers = _RecordingHandler.requests[-1] + assert "authorization" not in headers + assert "api-key" not in headers + + +def test_azure_anthropic_probe_drops_api_key_and_bearer_on_redirect(): + from hermes_cli import azure_detect + + sink = _server() + source = ThreadingHTTPServer(("127.0.0.1", 0), _LmStudioSourceHandler) + Thread(target=source.serve_forever, daemon=True).start() + _RecordingHandler.requests = [] + _LmStudioSourceHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + try: + azure_detect._probe_anthropic_messages( + f"http://127.0.0.1:{source.server_port}", "azure-secret" + ) + finally: + source.shutdown() + sink.shutdown() + + _, headers = _RecordingHandler.requests[-1] + assert "authorization" not in headers + assert "api-key" not in headers + + +def test_lmstudio_load_post_drops_bearer_on_redirect(monkeypatch): + from hermes_cli import models + + sink = _server() + source = ThreadingHTTPServer(("127.0.0.1", 0), _LmStudioSourceHandler) + Thread(target=source.serve_forever, daemon=True).start() + _RecordingHandler.requests = [] + _LmStudioSourceHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + monkeypatch.setattr( + models, + "_lmstudio_fetch_raw_models", + lambda **_kwargs: [ + {"id": "model", "max_context_length": 8192, "loaded_instances": []} + ], + ) + try: + loaded = models.ensure_lmstudio_model_loaded( + "model", + f"http://127.0.0.1:{source.server_port}", + api_key="lm-secret", + target_context_length=4096, + timeout=3, + ) + finally: + source.shutdown() + sink.shutdown() + + assert loaded == 4096 + method, headers = _RecordingHandler.requests[-1] + assert method == "GET" + assert "authorization" not in headers + assert "content-type" not in headers diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index f8ee073b138..46d770410fd 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -340,6 +340,56 @@ def test_codex_dashboard_worker_persists_inside_session_profile(tmp_path, monkey ws._oauth_sessions.pop(sid, None) +def test_codex_dashboard_start_rewords_device_authorization_error(monkeypatch): + from hermes_cli import web_server as ws + + before_sessions = set(ws._oauth_sessions) + + class _Resp: + status_code = 400 + text = "Enable device code authorization" + + def json(self): + return { + "error": { + "message": "Enable device code authorization", + "code": "device_authorization_not_enabled", + } + } + + class _Client: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def post(self, url, **kwargs): + assert url.endswith("/deviceauth/usercode") + return _Resp() + + monkeypatch.setattr(httpx, "Client", _Client) + + try: + resp = client.post( + "/api/providers/oauth/openai-codex/start", + headers=HEADERS, + ) + + assert resp.status_code == 500 + detail = resp.json()["detail"] + assert "OpenAI rejected the device-code login request" in detail + assert "Enable device-code authorization in OpenAI" in detail + assert "click Login again" in detail + assert "hermes auth" not in detail + finally: + for sid in set(ws._oauth_sessions) - before_sessions: + ws._oauth_sessions.pop(sid, None) + + def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch): from hermes_cli import auth as auth_mod from hermes_cli import web_server as ws diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 519ccf1518e..f88d60c56c5 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -863,6 +863,107 @@ class TestWebServerEndpoints: ) assert resp.status_code == 401 + # ── POST /api/chat/image-upload (browser clipboard/drop images) ───── + + def test_chat_image_upload_writes_to_default_profile_images(self): + from hermes_constants import get_hermes_home + + data_url = ( + "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk" + "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + ) + + resp = self.client.post( + "/api/chat/image-upload", + json={"data_url": data_url, "filename": "../../clip.png"}, + ) + + assert resp.status_code == 200 + data = resp.json() + target = Path(data["path"]) + assert data["ok"] is True + assert data["mime_type"] == "image/png" + assert target.parent == get_hermes_home() / "images" + assert target.name.startswith("dashboard_") + assert target.name.endswith("_clip.png") + assert target.is_file() + assert target.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + + def test_chat_image_upload_writes_to_requested_profile_images(self): + from hermes_cli import profiles as profiles_mod + + worker_home = profiles_mod.get_profile_dir("worker") + worker_home.mkdir(parents=True) + + resp = self.client.post( + "/api/chat/image-upload?profile=worker", + json={ + "data_url": "data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAA=", + "filename": "drop.gif", + }, + ) + + assert resp.status_code == 200 + target = Path(resp.json()["path"]) + assert target.parent == worker_home / "images" + assert target.is_file() + assert target.read_bytes().startswith(b"GIF89a") + + def test_chat_image_upload_rejects_non_image_payload(self): + resp = self.client.post( + "/api/chat/image-upload", + json={"data_url": "data:text/plain;base64,aGVsbG8="}, + ) + + assert resp.status_code == 400 + assert "image" in resp.json()["detail"].lower() + + def test_chat_image_upload_rejects_spoofed_image_payload(self): + resp = self.client.post( + "/api/chat/image-upload", + json={"data_url": "data:image/png;base64,aGVsbG8=", "filename": "fake.png"}, + ) + + assert resp.status_code == 400 + assert "unsupported image type" in resp.json()["detail"].lower() + + def test_chat_image_upload_rejects_unknown_profile(self): + resp = self.client.post( + "/api/chat/image-upload?profile=missing-profile", + json={"data_url": "data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAA="}, + ) + + assert resp.status_code == 404 + assert "does not exist" in resp.json()["detail"] + + def test_chat_image_upload_enforces_image_size_cap(self, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setattr(web_server, "_CHAT_IMAGE_UPLOAD_MAX_BYTES", 4) + + resp = self.client.post( + "/api/chat/image-upload", + json={ + "data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE=", + "filename": "large.png", + }, + ) + + assert resp.status_code == 413 + assert "too large" in resp.json()["detail"].lower() + + def test_chat_image_upload_requires_auth(self): + from hermes_cli.web_server import _SESSION_HEADER_NAME + + resp = self.client.post( + "/api/chat/image-upload", + json={"data_url": "data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAA="}, + headers={_SESSION_HEADER_NAME: "wrong-token"}, + ) + + assert resp.status_code == 401 + # ── Dashboard font override ───────────────────────────────────────── def test_get_dashboard_font_defaults_to_theme(self): @@ -1088,6 +1189,112 @@ class TestWebServerEndpoints: resp = self.client.patch("/api/sessions/does-not-exist", json={"title": "x"}) assert resp.status_code == 404 + def test_import_sessions_endpoint_imports_exported_json(self): + from hermes_state import SessionDB + + payload = { + "id": "imported-web-session", + "source": "cli", + "title": "Imported from dashboard", + "started_at": 100.0, + "ended_at": 110.0, + "end_reason": "complete", + "messages": [ + {"role": "user", "content": "hello", "timestamp": 101.0}, + {"role": "assistant", "content": "hi", "timestamp": 102.0}, + ], + } + + resp = self.client.post("/api/sessions/import", json={"sessions": [payload]}) + assert resp.status_code == 200 + data = resp.json() + assert data["imported"] == 1 + assert data["skipped"] == 0 + + db = SessionDB() + try: + session = db.get_session("imported-web-session") + assert session["title"] == "Imported from dashboard" + assert session["message_count"] == 2 + assert [m["content"] for m in db.get_messages("imported-web-session")] == [ + "hello", + "hi", + ] + finally: + db.close() + + duplicate = self.client.post("/api/sessions/import", json={"sessions": [payload]}) + assert duplicate.status_code == 200 + assert duplicate.json()["skipped_ids"] == ["imported-web-session"] + + invalid = self.client.post( + "/api/sessions/import", + json={"sessions": [{"source": "cli", "messages": []}]}, + ) + assert invalid.status_code == 400 + assert invalid.json()["detail"]["errors"] == [ + {"index": 0, "error": "session id is required"} + ] + + def test_import_sessions_endpoint_rejects_oversized_stream(self): + import hermes_cli.web_server as web_server + + payload = b'{"sessions":[]}' + b" " * web_server._SESSION_IMPORT_MAX_BYTES + response = self.client.post( + "/api/sessions/import", + content=payload, + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 413 + assert response.json() == {"detail": "Session import payload is too large"} + + def test_import_sessions_endpoint_rejects_metadata_that_would_break_session_list(self): + invalid = self.client.post( + "/api/sessions/import", + json={ + "sessions": [ + { + "id": "bad-model-config", + "source": "cli", + "model_config": "{not-json", + "messages": [], + } + ] + }, + ) + + assert invalid.status_code == 400 + assert invalid.json()["detail"]["errors"] == [ + { + "index": 0, + "session_id": "bad-model-config", + "error": "model_config must be valid JSON", + } + ] + listed = self.client.get("/api/sessions") + assert listed.status_code == 200 + + @pytest.mark.parametrize( + "message", + [{"content": "missing role"}, {"role": None, "content": "null role"}], + ) + def test_import_sessions_endpoint_rejects_missing_or_null_message_role(self, message): + response = self.client.post( + "/api/sessions/import", + json={"sessions": [{"id": "bad-message-role", "messages": [message]}]}, + ) + + assert response.status_code == 400 + assert response.json()["detail"]["errors"] == [ + { + "index": 0, + "session_id": "bad-message-role", + "error": "messages[0].role must be a non-empty string", + } + ] + assert self.client.get("/api/sessions").status_code == 200 + def test_archive_session_via_patch(self): """PATCH archived=true soft-hides a session; archived=false restores it.""" from hermes_state import SessionDB @@ -3438,6 +3645,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"}} diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py index 185b7bac32a..abcdbb681fa 100644 --- a/tests/hermes_cli/test_web_server_cron_profiles.py +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -1,5 +1,7 @@ """Regression tests for dashboard cron job profile routing.""" +from concurrent.futures import ThreadPoolExecutor +import json from queue import Empty, SimpleQueue import threading @@ -34,7 +36,7 @@ def _drain_queue(q): return values -def test_call_cron_for_profile_routes_storage_and_restores_globals(isolated_profiles): +def test_call_cron_for_profile_routes_storage_without_mutating_globals(isolated_profiles): from cron import jobs as cron_jobs from hermes_cli import web_server @@ -62,6 +64,135 @@ def test_call_cron_for_profile_routes_storage_and_restores_globals(isolated_prof assert cron_jobs.OUTPUT_DIR == old_output_dir +def test_fire_cron_job_scopes_store_and_runtime_home_together( + isolated_profiles, + monkeypatch, +): + """A profile fire must execute and persist under the same profile home.""" + from cron import jobs as cron_jobs + from cron import scheduler + from hermes_cli import web_server + + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + default_home = isolated_profiles["default"] + worker_home = isolated_profiles["worker_alpha"] + monkeypatch.setattr(scheduler, "_hermes_home", None) + captured = {} + + class RecordingProvider: + def fire_due(self, job_id, *, adapters=None, loop=None): + captured["job_id"] = job_id + captured["runtime_home"] = scheduler._get_hermes_home() + captured["jobs_file"] = cron_jobs._current_cron_store().jobs_file + return True + + monkeypatch.setattr( + "cron.scheduler_provider.resolve_cron_scheduler", + lambda: RecordingProvider(), + ) + + outer_token = set_hermes_home_override(default_home) + try: + assert web_server._fire_cron_job_for_profile("worker_alpha", "worker-job") is True + assert captured == { + "job_id": "worker-job", + "runtime_home": worker_home, + "jobs_file": worker_home / "cron" / "jobs.json", + } + assert scheduler._get_hermes_home() == default_home + finally: + reset_hermes_home_override(outer_token) + + +def test_profile_call_cannot_retarget_ticker_store_mid_write( + isolated_profiles, + monkeypatch, +): + """A dashboard profile call must not redirect a concurrent ticker save.""" + from cron import jobs as cron_jobs + from hermes_cli import web_server + + default_cron = isolated_profiles["default"] / "cron" + worker_cron = isolated_profiles["worker_alpha"] / "cron" + default_file = default_cron / "jobs.json" + worker_file = worker_cron / "jobs.json" + default_job = { + "id": "default-job", + "name": "default job", + "schedule": {"kind": "interval", "minutes": 60}, + "next_run_at": "2026-07-09T00:00:00+00:00", + } + worker_job = { + "id": "worker-job", + "name": "worker job", + "schedule": {"kind": "interval", "minutes": 60}, + "next_run_at": "2026-07-09T00:00:00+00:00", + } + default_file.write_text(json.dumps({"jobs": [default_job]}), encoding="utf-8") + worker_file.write_text(json.dumps({"jobs": [worker_job]}), encoding="utf-8") + + monkeypatch.setattr(cron_jobs, "CRON_DIR", default_cron) + monkeypatch.setattr(cron_jobs, "JOBS_FILE", default_file) + monkeypatch.setattr(cron_jobs, "OUTPUT_DIR", default_cron / "output") + monkeypatch.setattr( + cron_jobs, + "compute_next_run", + lambda _schedule, _last_run_at=None: "2026-07-10T00:00:00+00:00", + ) + + ticker_loaded = threading.Event() + release_ticker = threading.Event() + profile_entered = threading.Event() + ticker_done = threading.Event() + ticker_thread = threading.local() + original_load_jobs = cron_jobs.load_jobs + + def blocking_load_jobs(): + loaded = original_load_jobs() + if getattr(ticker_thread, "active", False): + ticker_loaded.set() + assert release_ticker.wait(5), "profile call did not enter in time" + return loaded + + def hold_profile_call(): + profile_entered.set() + assert ticker_done.wait(5), "ticker did not finish in time" + return True + + def run_ticker_write(): + ticker_thread.active = True + try: + return cron_jobs.advance_next_run("default-job") + finally: + ticker_done.set() + + monkeypatch.setattr(cron_jobs, "load_jobs", blocking_load_jobs) + monkeypatch.setattr(cron_jobs, "_hold_profile_call", hold_profile_call, raising=False) + + with ThreadPoolExecutor(max_workers=2) as pool: + ticker_future = pool.submit(run_ticker_write) + assert ticker_loaded.wait(5), "ticker did not load the default store" + profile_future = pool.submit( + web_server._call_cron_for_profile, + "worker_alpha", + "_hold_profile_call", + ) + assert profile_entered.wait(5), "profile call did not retarget its store" + release_ticker.set() + assert ticker_future.result(timeout=5) is True + assert profile_future.result(timeout=5) is True + + default_saved = json.loads(default_file.read_text(encoding="utf-8"))["jobs"] + worker_saved = json.loads(worker_file.read_text(encoding="utf-8"))["jobs"] + assert [job["id"] for job in worker_saved] == ["worker-job"] + assert [job["id"] for job in default_saved] == ["default-job"] + assert default_saved[0]["next_run_at"] == "2026-07-10T00:00:00+00:00" + + @pytest.mark.asyncio async def test_list_cron_jobs_all_includes_default_and_named_profiles(isolated_profiles): from hermes_cli import web_server diff --git a/tests/hermes_cli/test_yolo_startup_order.py b/tests/hermes_cli/test_yolo_startup_order.py new file mode 100644 index 00000000000..6d30b776fbd --- /dev/null +++ b/tests/hermes_cli/test_yolo_startup_order.py @@ -0,0 +1,77 @@ +"""Regression tests for #60328: --yolo must set HERMES_YOLO_MODE in +main() before _prepare_agent_startup() triggers tool imports. + +The freeze mechanism in tools.approval (_YOLO_MODE_FROZEN) is correct +by design (PR #7994). The bug was that main() set the env var inside +cmd_chat(), which runs *after* _prepare_agent_startup() has already +imported tools.approval and frozen the constant to False. + +These tests verify the ordering in main() itself: the env var must +already be set at the moment _prepare_agent_startup() is called. +If someone moves the assignment back into cmd_chat(), these tests +fail — catching the exact #60328 regression. +""" + +import os +import sys + + +def _run_main_and_capture_yolo_at_startup(monkeypatch, argv): + """Run main() with *argv*, capturing HERMES_YOLO_MODE at the + moment _prepare_agent_startup is called. + + Returns the captured env var value (or None if unset). + """ + yolo_at_startup = {} + + def spy_prepare_startup(args): + yolo_at_startup["value"] = os.environ.get("HERMES_YOLO_MODE") + + monkeypatch.setattr( + "hermes_cli.main._prepare_agent_startup", spy_prepare_startup + ) + # Stub cmd_chat so main() returns cleanly without entering chat. + monkeypatch.setattr("hermes_cli.main.cmd_chat", lambda args: None) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.setattr(sys, "argv", argv) + + from hermes_cli.main import main as cli_main + + cli_main() + + return yolo_at_startup.get("value") + + +def test_top_level_yolo_flag_sets_env_before_startup(monkeypatch): + """hermes --yolo must set HERMES_YOLO_MODE before + _prepare_agent_startup imports tools.approval.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes", "--yolo"] + ) + assert result == "1", ( + "HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was " + "called from main() with --yolo. This is the #60328 regression: " + "the env var is set too late (inside cmd_chat, after tool imports)." + ) + + +def test_chat_subcommand_yolo_flag_sets_env_before_startup(monkeypatch): + """hermes chat --yolo must also set HERMES_YOLO_MODE before + _prepare_agent_startup.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes", "chat", "--yolo"] + ) + assert result == "1", ( + "HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was " + "called from main() with 'chat --yolo'." + ) + + +def test_no_yolo_flag_leaves_env_unset_at_startup(monkeypatch): + """Without --yolo, HERMES_YOLO_MODE must not be set at startup.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes"] + ) + assert result is None, ( + "HERMES_YOLO_MODE was unexpectedly set at startup without --yolo." + ) diff --git a/tests/plugins/image_gen/test_deepinfra_provider.py b/tests/plugins/image_gen/test_deepinfra_provider.py new file mode 100644 index 00000000000..5cd3c0ffcef --- /dev/null +++ b/tests/plugins/image_gen/test_deepinfra_provider.py @@ -0,0 +1,129 @@ +"""Tests for the bundled DeepInfra image_gen plugin. + +Invariants only — no snapshots of specific model ids. Most surface-level +contracts (network-failure → empty list, tag filtering, no-model error) +are covered by the shared tag-filter test in +``tests/hermes_cli/test_api_key_providers.py``; these two tests pin the +plugin-specific bits that wrapper doesn't reach. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +import plugins.image_gen.deepinfra as deepinfra_plugin + + +# 1×1 transparent PNG — valid bytes for save_b64_image() +_PNG_HEX = ( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4" + "890000000d49444154789c6300010000000500010d0a2db40000000049454e44" + "ae426082" +) + + +def _b64_png() -> str: + import base64 + + return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode() + + +@pytest.fixture(autouse=True) +def _isolation(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + import hermes_cli.models as _models_mod + monkeypatch.setattr(_models_mod, "_deepinfra_catalog_cache", {}) + monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key") + yield + + +def test_list_models_filters_by_image_gen_tag(monkeypatch): + """Plugin-side wiring: list_models() returns only ``image-gen``-tagged + catalog entries and surfaces pricing + default dims when present.""" + import json + import hermes_cli.models as models + + class _Resp: + def __enter__(self): return self + def __exit__(self, *a): return False + def read(self): + return json.dumps({"data": [ + {"id": "vendor/chat", "metadata": {"tags": ["chat"]}}, + {"id": "vendor/img", "metadata": { + "tags": ["image-gen"], + "pricing": {"per_image_unit": 0.005}, + "default_width": 1024, + }}, + ]}).encode() + + monkeypatch.setattr( + models, "_urlopen_model_catalog_request", lambda *a, **kw: _Resp() + ) + rows = deepinfra_plugin.DeepInfraImageGenProvider().list_models() + ids = {row["id"] for row in rows} + assert ids == {"vendor/img"} + img = next(row for row in rows if row["id"] == "vendor/img") + assert "price" in img and img["default_width"] == 1024 + + +def test_generate_calls_openai_sdk_with_deepinfra_base_url(monkeypatch): + """Happy path: pinned model → openai SDK called with DeepInfra + base_url + Bearer key → b64 saved to cache.""" + monkeypatch.setenv("DEEPINFRA_IMAGE_MODEL", "vendor/test-img") + captured: dict = {} + + class _FakeImages: + def generate(self, **kwargs): + captured["kwargs"] = kwargs + return SimpleNamespace(data=[SimpleNamespace(b64_json=_b64_png(), url=None)]) + + class _FakeClient: + def __init__(self, api_key=None, base_url=None): + captured["api_key"] = api_key + captured["base_url"] = base_url + self.images = _FakeImages() + + fake_openai = MagicMock() + fake_openai.OpenAI = _FakeClient + with patch.dict("sys.modules", {"openai": fake_openai}): + result = deepinfra_plugin.DeepInfraImageGenProvider().generate( + prompt="a cat", aspect_ratio="square", + ) + + assert result["success"] is True + assert "deepinfra" in captured["base_url"] + assert captured["api_key"] == "test-key" + assert captured["kwargs"]["model"] == "vendor/test-img" + + +@pytest.mark.parametrize( + "kwargs", + [ + {"image_url": "https://example.com/source.png"}, + {"reference_image_urls": ["https://example.com/reference.png"]}, + ], +) +def test_generate_rejects_unsupported_edit_inputs_without_calling_sdk( + monkeypatch, kwargs +): + monkeypatch.setenv("DEEPINFRA_IMAGE_MODEL", "vendor/test-img") + fake_openai = MagicMock() + with patch.dict("sys.modules", {"openai": fake_openai}): + result = deepinfra_plugin.DeepInfraImageGenProvider().generate( + prompt="edit this", **kwargs + ) + + assert result["success"] is False + assert result["error_type"] == "modality_unsupported" + assert result["provider"] == "deepinfra" + fake_openai.OpenAI.assert_not_called() + + +def test_capabilities_advertise_text_to_image_only(): + assert deepinfra_plugin.DeepInfraImageGenProvider().capabilities() == { + "modalities": ["text"], + "max_reference_images": 0, + } diff --git a/tests/plugins/image_gen/test_openai_codex_provider.py b/tests/plugins/image_gen/test_openai_codex_provider.py index dc8560c8b3c..338ea97418a 100644 --- a/tests/plugins/image_gen/test_openai_codex_provider.py +++ b/tests/plugins/image_gen/test_openai_codex_provider.py @@ -9,6 +9,7 @@ endpoint. from __future__ import annotations import importlib +import json from pathlib import Path import pytest @@ -307,6 +308,91 @@ class TestGenerate: assert result["error_type"] == "api_error" assert "cloudflare 403" in result["error"] + def test_unsupported_image_tool_returns_capability_error(self, provider, monkeypatch): + monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token") + + def _unsupported(*args, **kwargs): + raise codex_plugin.CodexImageGenerationUnsupportedError( + "Tool choice 'image_generation' not found in 'tools' parameter." + ) + + monkeypatch.setattr(codex_plugin, "_collect_image_b64", _unsupported) + + result = provider.generate("a cat") + + assert result["success"] is False + assert result["error_type"] == "capability_unsupported" + assert "current Codex account" in result["error"] + assert "OpenAI API key, FAL, or xAI" in result["error"] + + +class TestCapabilityErrorDetection: + @pytest.mark.parametrize( + "body", + [ + "Tool choice 'image_generation' not found in 'tools' parameter.", + '{"error":{"message":"Tool choice \'image_generation\' not found in \'tools\' parameter."}}', + ], + ) + def test_detects_exact_codex_image_tool_rejection(self, body): + assert codex_plugin._is_image_generation_unsupported_error(400, body) is True + + @pytest.mark.parametrize( + ("status_code", "body"), + [ + (401, "Tool choice 'image_generation' not found in 'tools' parameter."), + (400, "Tool choice 'web_search' not found in 'tools' parameter."), + (400, "The image_generation request was rejected by moderation."), + (500, "Tool choice 'image_generation' not found in 'tools' parameter."), + ], + ) + def test_does_not_misclassify_other_failures(self, status_code, body): + assert codex_plugin._is_image_generation_unsupported_error(status_code, body) is False + + def test_does_not_match_error_message_with_extra_text(self): + body = json.dumps({ + "error": { + "message": ( + "Tool choice 'image_generation' not found in 'tools' parameter " + "because the request is malformed." + ) + } + }) + + assert codex_plugin._is_image_generation_unsupported_error(400, body) is False + + def test_collect_classifies_exact_http_error_after_large_metadata(self, monkeypatch): + import httpx + + body = json.dumps({ + "metadata": "x" * 600, + "error": { + "message": "Tool choice 'image_generation' not found in 'tools' parameter." + }, + }) + + def _handler(request): + return httpx.Response(400, text=body, request=request) + + real_client = httpx.Client + monkeypatch.setattr( + httpx, + "Client", + lambda *args, **kwargs: real_client( + transport=httpx.MockTransport(_handler), + headers=kwargs.get("headers"), + timeout=kwargs.get("timeout"), + ), + ) + + with pytest.raises(codex_plugin.CodexImageGenerationUnsupportedError): + codex_plugin._collect_image_b64( + "codex-token", + prompt="a cat", + size="1024x1024", + quality="low", + ) + # ── Plugin entry point ────────────────────────────────────────────────────── diff --git a/tests/plugins/memory/test_holographic_store.py b/tests/plugins/memory/test_holographic_store.py new file mode 100644 index 00000000000..df351864b00 --- /dev/null +++ b/tests/plugins/memory/test_holographic_store.py @@ -0,0 +1,243 @@ +"""Tests for the holographic MemoryStore shared-connection registry. + +MemoryStore instances pointing at the same database file must share one +process-wide SQLite connection and one re-entrant lock. Multiple providers +coexist in a single process (the main agent plus every delegate_task +subagent); when each instance owned a private connection they raced as +independent WAL writers and intermittently failed with "database is locked". + +Covers: connection sharing/refcounting, close() semantics, cross-instance +visibility, concurrent multi-instance writers, and write-lock release after +a failed write. +""" + +import sqlite3 +import threading + +import pytest + +from plugins.memory.holographic.store import MemoryStore + + +@pytest.fixture(autouse=True) +def _clean_shared_registry(): + """Each test starts and ends with an empty shared-connection registry.""" + # Drop any leakage from earlier tests in the same process. + for entry in list(MemoryStore._shared.values()): + try: + entry["conn"].close() + except sqlite3.Error: + pass + MemoryStore._shared.clear() + yield + leaked = list(MemoryStore._shared) + for entry in list(MemoryStore._shared.values()): + try: + entry["conn"].close() + except sqlite3.Error: + pass + MemoryStore._shared.clear() + assert not leaked, f"test leaked shared connections: {leaked}" + + +@pytest.fixture +def db_path(tmp_path): + return tmp_path / "memory_store.db" + + +class TestSharedConnection: + def test_same_path_shares_one_connection(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + try: + assert a._conn is b._conn + assert a._lock is b._lock + assert len(MemoryStore._shared) == 1 + assert MemoryStore._shared[str(a.db_path)]["refs"] == 2 + finally: + a.close() + b.close() + + def test_different_paths_get_distinct_connections(self, tmp_path): + a = MemoryStore(tmp_path / "one.db") + b = MemoryStore(tmp_path / "two.db") + try: + assert a._conn is not b._conn + assert len(MemoryStore._shared) == 2 + finally: + a.close() + b.close() + + def test_symlinked_path_shares_connection(self, tmp_path): + """A symlink to the same DB file must hit the same registry entry — + otherwise two connections to one file silently reintroduce the + multi-writer contention the registry exists to prevent.""" + real_dir = tmp_path / "real" + real_dir.mkdir() + link_dir = tmp_path / "link" + link_dir.symlink_to(real_dir) + + a = MemoryStore(real_dir / "memory_store.db") + b = MemoryStore(link_dir / "memory_store.db") + try: + assert a._conn is b._conn + assert len(MemoryStore._shared) == 1 + finally: + a.close() + b.close() + + def test_writes_visible_across_instances(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + try: + fact_id = a.add_fact("Hermes likes shared connections", category="test") + facts = b.list_facts(category="test") + assert [f["fact_id"] for f in facts] == [fact_id] + finally: + a.close() + b.close() + + def test_schema_initialised_once_per_connection(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) # must not re-run schema init / WAL probe + try: + assert MemoryStore._shared[str(a.db_path)]["ready"] is True + b.add_fact("schema still works") + finally: + a.close() + b.close() + + +class TestCloseSemantics: + def test_closing_one_instance_keeps_sibling_alive(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + a.close() + try: + # The shared connection must survive the sibling's close(). + fact_id = b.add_fact("survivor write") + assert fact_id > 0 + finally: + b.close() + + def test_last_close_releases_connection(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + conn = a._conn + a.close() + b.close() + assert MemoryStore._shared == {} + with pytest.raises(sqlite3.ProgrammingError): + conn.execute("SELECT 1") + + def test_close_is_idempotent(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + a.close() + a.close() # double close must not steal b's reference + try: + b.add_fact("still alive after double close") + assert MemoryStore._shared[str(b.db_path)]["refs"] == 1 + finally: + b.close() + + def test_context_manager_releases_reference(self, db_path): + with MemoryStore(db_path) as store: + store.add_fact("context managed") + assert MemoryStore._shared == {} + + def test_reopen_after_full_close(self, db_path): + with MemoryStore(db_path) as store: + store.add_fact("first lifetime") + with MemoryStore(db_path) as store: + facts = store.list_facts() + assert [f["content"] for f in facts] == ["first lifetime"] + + +class TestConcurrency: + def test_concurrent_multi_instance_writers(self, db_path): + """Many instances writing from many threads must never hit + 'database is locked' — the failure mode of per-instance connections.""" + n_threads, n_facts = 8, 15 + errors: list[BaseException] = [] + + def writer(idx: int) -> None: + store = MemoryStore(db_path) + try: + for i in range(n_facts): + store.add_fact(f"fact thread={idx} seq={i}", category="load") + except BaseException as exc: # noqa: BLE001 - recorded for assert + errors.append(exc) + finally: + store.close() + + threads = [threading.Thread(target=writer, args=(i,)) for i in range(n_threads)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"concurrent writers failed: {errors[:3]}" + with MemoryStore(db_path) as store: + facts = store.list_facts(category="load", limit=500) + assert len(facts) == n_threads * n_facts + assert MemoryStore._shared == {} + + def test_failed_write_does_not_pin_write_lock(self, db_path, monkeypatch): + """A write that raises mid-method must not leave an open transaction + holding the SQLite write lock (autocommit isolation_level=None).""" + broken = MemoryStore(db_path) + sibling = MemoryStore(db_path) + try: + monkeypatch.setattr( + MemoryStore, + "_rebuild_bank", + lambda self, category: (_ for _ in ()).throw(RuntimeError("boom")), + ) + with pytest.raises(RuntimeError, match="boom"): + broken.add_fact("write that fails after the INSERT") + monkeypatch.undo() + + # No dangling transaction: the connection reports autocommit state + # and the sibling can write immediately. + assert broken._conn.in_transaction is False + sibling.add_fact("sibling write right after the failure") + finally: + broken.close() + sibling.close() + + +class TestProviderShutdown: + """The provider's shutdown() must release its shared connection, not just + drop the reference. Leaving finalization to GC keeps the connection (and + its write lock) alive on a long-running gateway, which is exactly the + "database is locked" contention the shared-connection registry removes.""" + + def test_shutdown_releases_shared_connection(self, db_path): + from plugins.memory.holographic import HolographicMemoryProvider + + provider = HolographicMemoryProvider(config={"db_path": str(db_path)}) + provider.initialize("session-shutdown") + assert MemoryStore._shared[str(db_path)]["refs"] == 1 + + provider.shutdown() + + assert provider._store is None + assert MemoryStore._shared == {} + + def test_shutdown_keeps_sibling_provider_alive(self, db_path): + from plugins.memory.holographic import HolographicMemoryProvider + + a = HolographicMemoryProvider(config={"db_path": str(db_path)}) + b = HolographicMemoryProvider(config={"db_path": str(db_path)}) + a.initialize("session-a") + b.initialize("session-b") + assert MemoryStore._shared[str(db_path)]["refs"] == 2 + + a.shutdown() + # Sibling still holds a live, writable connection. + assert MemoryStore._shared[str(db_path)]["refs"] == 1 + assert b._store is not None + b._store.add_fact("write after sibling shutdown") + b.shutdown() + assert MemoryStore._shared == {} diff --git a/tests/plugins/model_providers/test_fireworks_profile.py b/tests/plugins/model_providers/test_fireworks_profile.py new file mode 100644 index 00000000000..0a04c7d97f9 --- /dev/null +++ b/tests/plugins/model_providers/test_fireworks_profile.py @@ -0,0 +1,81 @@ +"""Unit tests for the Fireworks AI provider profile. + +Pins the profile's contract without going live: identity, alias registration, +and the pay-as-you-go model defaults (direct catalog ``/models/`` +IDs, not the router-only tier). +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def fireworks_profile(): + """Resolve the registered Fireworks profile through the real discovery path.""" + # Importing model_tools triggers plugin discovery, registering the profile. + import model_tools # noqa: F401 + import providers + + profile = providers.get_provider_profile("fireworks") + assert profile is not None, "fireworks provider profile must be registered" + return profile + + +class TestFireworksIdentity: + def test_core_fields(self, fireworks_profile): + p = fireworks_profile + assert p.name == "fireworks" + assert p.auth_type == "api_key" + assert p.base_url == "https://api.fireworks.ai/inference/v1" + assert "FIREWORKS_API_KEY" in p.env_vars + assert "FIREWORKS_BASE_URL" not in p.env_vars + + def test_display_metadata_present(self, fireworks_profile): + # Prominence copy is surfaced in the picker; keep it non-empty rather + # than pinning exact marketing wording (that's expected to change). + assert fireworks_profile.display_name + assert fireworks_profile.description + assert fireworks_profile.signup_url.startswith("https://") + + +class TestFireworksHeaders: + def test_no_partner_attribution_headers(self, fireworks_profile): + assert "HTTP-Referer" not in fireworks_profile.default_headers + assert "X-Title" not in fireworks_profile.default_headers + + +class TestFireworksAliases: + @pytest.mark.parametrize("alias", ["fireworks-ai", "fw"]) + def test_alias_resolves_via_registry(self, fireworks_profile, alias): + import providers + + resolved = providers.get_provider_profile(alias) + assert resolved is not None + assert resolved.name == "fireworks" + + def test_aliases_declared_on_profile(self, fireworks_profile): + assert "fireworks-ai" in fireworks_profile.aliases + assert "fw" in fireworks_profile.aliases + + +class TestFireworksModelDefaults: + """Defaults must be usable with a standard pay-as-you-go key. + + PAYG keys address ``accounts/fireworks/models/...`` directly; the bundled + defaults target that (the BYOK motion) so a fresh key works out of the box, + and use the standard tier rather than turbo as the out-of-box default. + """ + + def test_aux_model_is_payg_model_not_router(self, fireworks_profile): + aux = fireworks_profile.default_aux_model + assert aux.startswith("accounts/fireworks/models/"), aux + assert "/routers/" not in aux + assert "turbo" not in aux.lower() + + def test_fallback_models_are_payg_models_not_routers(self, fireworks_profile): + assert fireworks_profile.fallback_models, "expected curated fallbacks" + for model in fireworks_profile.fallback_models: + assert model.startswith("accounts/fireworks/models/"), model + assert "/routers/" not in model + assert "turbo" not in model.lower(), model diff --git a/tests/plugins/model_providers/test_ollama_cloud_profile.py b/tests/plugins/model_providers/test_ollama_cloud_profile.py index de1e2be44da..15e798a2cd6 100644 --- a/tests/plugins/model_providers/test_ollama_cloud_profile.py +++ b/tests/plugins/model_providers/test_ollama_cloud_profile.py @@ -106,9 +106,9 @@ class TestOllamaCloudReasoningEffort: def test_unknown_effort_forwarded(self, ollama_cloud_profile): _, top_level = ollama_cloud_profile.build_api_kwargs_extras( - reasoning_config={"enabled": True, "effort": "ultra"}, + reasoning_config={"enabled": True, "effort": "future-tier"}, ) - assert top_level == {"reasoning_effort": "ultra"} + assert top_level == {"reasoning_effort": "future-tier"} class TestOllamaCloudFullKwargsIntegration: diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 9833ea21069..4c7f466ce8f 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -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.""" @@ -2265,3 +2362,127 @@ def test_dashboard_failed_card_highlight_class_exists(): assert "hermes-kanban-card--failed" in js assert "hermes-kanban-card--failed" in css assert "failedIds" in js + +# --------------------------------------------------------------------------- +# Final result visibility for Done cards +# --------------------------------------------------------------------------- + + +def test_task_detail_exposes_result_and_latest_summary_separately(client): + """The drawer receives both source fields without a duplicate alias.""" + r = client.post( + "/api/plugins/kanban/tasks", + json={"title": "Task with explicit result"}, + ) + task_id = r.json()["task"]["id"] + client.patch( + f"/api/plugins/kanban/tasks/{task_id}", + json={"status": "done", "result": "The final answer is 42.", "summary": "short handoff"}, + ) + r = client.get(f"/api/plugins/kanban/tasks/{task_id}") + assert r.status_code == 200 + data = r.json()["task"] + assert data["result"] == "The final answer is 42." + assert data["latest_summary"] == "short handoff" + assert "final_result" not in data + + +def test_task_detail_exposes_latest_summary_when_result_is_empty(client): + """Summary-only completions remain available to the drawer fallback.""" + conn = kb.connect() + task_id = kb.create_task(conn, title="Task with only run summary") + kb.claim_task(conn, task_id) + kb.complete_task(conn, task_id, summary="Report written to /output/report.md") + conn.close() + + r = client.get(f"/api/plugins/kanban/tasks/{task_id}") + assert r.status_code == 200 + data = r.json()["task"] + assert data["status"] == "done" + assert not data["result"] + assert data["latest_summary"] == "Report written to /output/report.md" + + +def test_task_detail_latest_summary_none_when_nothing_recorded(client): + """When no run summary exists, the existing field remains None.""" + r = client.post( + "/api/plugins/kanban/tasks", + json={"title": "Task with no result at all"}, + ) + task_id = r.json()["task"]["id"] + r = client.get(f"/api/plugins/kanban/tasks/{task_id}") + assert r.status_code == 200 + assert r.json()["task"]["latest_summary"] is None + + +def test_board_tasks_include_latest_summary(client): + """Board cards already expose the summary used by the drawer fallback.""" + conn = kb.connect() + task_id = kb.create_task(conn, title="Board card with summary only") + kb.claim_task(conn, task_id) + kb.complete_task(conn, task_id, summary="Done: see attachment") + conn.close() + + r = client.get("/api/plugins/kanban/board") + assert r.status_code == 200 + done_col = next(c for c in r.json()["columns"] if c["name"] == "done") + card = next((t for t in done_col["tasks"] if t["id"] == task_id), None) + assert card is not None + assert "Done: see attachment" in card["latest_summary"] + + +def test_dashboard_done_final_result_section_rendered_from_summary(): + """Frontend must render Final Result section from run summary when task.result is empty.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + assert "t.result || t.latest_summary" in dist + assert "Final Result (run summary)" in dist + assert "No final result was recorded" in dist + assert "orchestrator" in dist or "parent task" in dist + + +def test_task_detail_includes_child_result_summaries(client): + """Parent drawers should receive the child results they need to render.""" + with kb.connect() as conn: + parent = kb.create_task(conn, title="Research topic") + child = kb.create_task(conn, title="Collect sources") + kb.link_tasks(conn, parent, child) + kb.complete_task(conn, parent, summary="Delegated research to child tasks.") + kb.recompute_ready(conn) + kb.complete_task(conn, child, summary="Collected five primary sources.") + + response = client.get(f"/api/plugins/kanban/tasks/{parent}") + + assert response.status_code == 200 + assert response.json()["child_results"] == [ + { + "id": child, + "title": "Collect sources", + "status": "done", + "latest_summary": "Collected five primary sources.", + "result": None, + } + ] + + +def test_dashboard_final_result_uses_existing_fields_without_alias(): + """The drawer should not duplicate result/summary into another API field.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + api = (repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py").read_text() + + assert "var finalResult = t.result || t.latest_summary || null;" in dist + assert "t.final_result" not in dist + assert 'd["final_result"]' not in api + + +def test_dashboard_parent_notice_and_child_results_use_detail_links(): + """Parent detection must use links.children, which exists in task detail.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + detail = dist[dist.index("function TaskDetail"):] + + assert "links.children.length > 0" in detail + assert "t.link_counts" not in detail + assert "Child Results" in detail + assert "props.data.child_results" in detail diff --git a/tests/plugins/video_gen/test_deepinfra_provider.py b/tests/plugins/video_gen/test_deepinfra_provider.py new file mode 100644 index 00000000000..0c0e82936bc --- /dev/null +++ b/tests/plugins/video_gen/test_deepinfra_provider.py @@ -0,0 +1,218 @@ +"""Tests for the bundled DeepInfra video_gen plugin. + +Invariants only — no snapshots of specific model ids. The plugin is a thin +subclass of ``agent.video_gen_provider.OpenAICompatibleVideoGenProvider``; +these tests pin the plugin-specific bits (tag filtering, identity) and the +shared base behaviour exercised through it (OpenAI ``videos`` call shape, +t2v vs i2v routing, download → save). +""" + +from __future__ import annotations + +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +import plugins.video_gen.deepinfra as deepinfra_plugin + + +@pytest.fixture(autouse=True) +def _isolation(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + import hermes_cli.models as _models_mod + monkeypatch.setattr(_models_mod, "_deepinfra_catalog_cache", {}) + monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key") + yield + + +def test_identity_and_availability(monkeypatch): + p = deepinfra_plugin.DeepInfraVideoGenProvider() + assert p.name == "deepinfra" + assert p.display_name == "DeepInfra" + assert p._base_url() == "https://api.deepinfra.com/v1/openai" + assert p.is_available() is True + monkeypatch.delenv("DEEPINFRA_API_KEY", raising=False) + assert p.is_available() is False + + +def test_list_models_filters_by_video_gen_tag(monkeypatch): + """list_models() returns only ``video-gen``-tagged catalog entries.""" + import hermes_cli.models as _models_mod + + def _fake_by_tag(tag, **kw): + assert tag == "video-gen" + return [ + {"id": "vendor/p-video", "metadata": {"description": "fast t2v"}}, + {"id": "vendor/wan-t2v", "metadata": {}}, + ] + + monkeypatch.setattr(_models_mod, "_fetch_deepinfra_models_by_tag", _fake_by_tag) + rows = deepinfra_plugin.DeepInfraVideoGenProvider().list_models() + ids = {row["id"] for row in rows} + assert ids == {"vendor/p-video", "vendor/wan-t2v"} + assert all("display" in r for r in rows) + + +def _fake_openai_with_capture(captured: dict, *, status="succeeded", + data=None, download=b"\x00\x00mp4bytes"): + """Build a fake ``openai`` module whose videos resource records the call. + + Defaults mirror the real DeepInfra job shape: status ``"succeeded"`` and a + ``data`` list carrying the delivery URL. + """ + if data is None: + data = [{"url": "https://cdn.example/out.mp4"}] + + class _FakeVideos: + def create(self, **kwargs): + captured["kwargs"] = kwargs + # Return a terminal status immediately so the bounded poll in + # OpenAICompatibleVideoGenProvider._create_and_poll exits without + # calling retrieve() or sleeping. + return SimpleNamespace(status=status, id="vid_123", error=None, data=data) + + def retrieve(self, video_id): + return SimpleNamespace(status=status, id=video_id, error=None, data=data) + + def download_content(self, video_id): + captured["downloaded_id"] = video_id + return SimpleNamespace(read=lambda: download) + + class _FakeClient: + def __init__(self, api_key=None, base_url=None): + captured["api_key"] = api_key + captured["base_url"] = base_url + self.videos = _FakeVideos() + + fake = MagicMock() + fake.OpenAI = _FakeClient + return fake + + +@contextmanager +def _mock_url_download(captured: dict, raise_exc: Exception | None = None): + """Patch the shared ``save_url_video`` helper the base provider calls.""" + import agent.video_gen_provider as base + from pathlib import Path + + def _fake_save_url_video(url, *, prefix="video", **kw): + captured["url"] = url + if raise_exc: + raise raise_exc + return Path(f"/home/x/.hermes/cache/videos/{prefix}_test.mp4") + + with patch.object(base, "save_url_video", _fake_save_url_video): + yield + + +def test_generate_text_to_video_downloads_url_and_saves_locally(): + """t2v happy path: SDK called with DeepInfra base_url + key; status + 'succeeded' + data[].url → bytes downloaded and saved to a local file.""" + captured: dict = {} + with patch.dict("sys.modules", {"openai": _fake_openai_with_capture(captured)}), \ + _mock_url_download(captured): + result = deepinfra_plugin.DeepInfraVideoGenProvider().generate( + prompt="a red cube rotating", model="vendor/test-vid", duration=5, + ) + assert result["success"] is True + assert result["modality"] == "text" + assert result["video"].endswith(".mp4") and "cache/videos" in result["video"] + assert captured["url"] == "https://cdn.example/out.mp4" + assert "deepinfra" in captured["base_url"] + assert captured["api_key"] == "test-key" + assert captured["kwargs"]["model"] == "vendor/test-vid" + assert captured["kwargs"]["seconds"] == "5" + # No image_url ⇒ no image-to-video field passed through. + assert "image_url" not in captured["kwargs"].get("extra_body", {}) + + +def test_generate_returns_url_when_local_save_fails(): + """If downloading the delivery URL fails, fall back to returning the URL.""" + captured: dict = {} + with patch.dict("sys.modules", {"openai": _fake_openai_with_capture(captured)}), \ + _mock_url_download(captured, raise_exc=OSError("network down")): + result = deepinfra_plugin.DeepInfraVideoGenProvider().generate( + prompt="x", model="vendor/test-vid", + ) + assert result["success"] is True + assert result["video"] == "https://cdn.example/out.mp4" + + +def test_generate_falls_back_to_download_when_no_url(): + """OpenAI/Sora style: no data[].url → download_content bytes saved locally.""" + captured: dict = {} + fake = _fake_openai_with_capture(captured, status="completed", data=[]) + with patch.dict("sys.modules", {"openai": fake}): + result = deepinfra_plugin.DeepInfraVideoGenProvider().generate( + prompt="x", model="vendor/test-vid", + ) + assert result["success"] is True + assert captured["downloaded_id"] == "vid_123" + assert result["video"].endswith(".mp4") + + +def test_generate_image_to_video_routes_via_extra_body(): + """Presence of image_url routes to i2v and rides in extra_body.""" + captured: dict = {} + with patch.dict("sys.modules", {"openai": _fake_openai_with_capture(captured)}), \ + _mock_url_download(captured): + result = deepinfra_plugin.DeepInfraVideoGenProvider().generate( + prompt="animate this", model="vendor/test-vid", + image_url="https://example.com/cat.jpg", negative_prompt="blurry", + ) + assert result["success"] is True + assert result["modality"] == "image" + extra = captured["kwargs"]["extra_body"] + assert extra["image_url"] == "https://example.com/cat.jpg" + assert extra["negative_prompt"] == "blurry" + + +def test_generate_errors_when_key_missing(monkeypatch): + monkeypatch.delenv("DEEPINFRA_API_KEY", raising=False) + result = deepinfra_plugin.DeepInfraVideoGenProvider().generate( + prompt="x", model="vendor/test-vid", + ) + assert result["success"] is False + assert result["error_type"] == "missing_credentials" + + +def test_generate_errors_when_job_not_completed(): + """A non-completed job status surfaces a JSON-serializable job_failed error. + + ``video.error`` is a structured SDK object (pydantic ``VideoCreateError``), + not a string — the provider must str() it so the response dict survives the + tool layer's ``json.dumps``. We simulate that with a non-serializable object. + """ + import json + + captured: dict = {} + fake = _fake_openai_with_capture(captured) + + class _NonSerializableError: + def __str__(self): + return "content policy violation" + + class _FailingVideos: + def create(self, **kwargs): + return SimpleNamespace( + status="failed", id="vid_x", error=_NonSerializableError(), data=None + ) + + def retrieve(self, video_id): # pragma: no cover - status already terminal + return SimpleNamespace(status="failed", id=video_id, error=None, data=None) + + def _client(api_key=None, base_url=None): + return SimpleNamespace(videos=_FailingVideos()) + + fake.OpenAI = _client + with patch.dict("sys.modules", {"openai": fake}): + result = deepinfra_plugin.DeepInfraVideoGenProvider().generate( + prompt="x", model="vendor/test-vid", + ) + assert result["success"] is False + assert result["error_type"] == "job_failed" + assert "content policy violation" in result["error"] + # Must not raise — this is the regression the str() guard prevents. + json.dumps(result) diff --git a/tests/providers/test_fetch_models_base_url.py b/tests/providers/test_fetch_models_base_url.py index 5db1f61d1cd..7ccbed044f2 100644 --- a/tests/providers/test_fetch_models_base_url.py +++ b/tests/providers/test_fetch_models_base_url.py @@ -117,6 +117,81 @@ class TestCustomProviderBaseUrlPassthrough: server.shutdown() +class _RedirectingHandler(BaseHTTPRequestHandler): + """Redirects /models to a configurable target and records received headers.""" + + redirect_to = "" # full URL to redirect /models to (set per test) + received_headers: dict = {} + + def do_GET(self): + if self.path.rstrip("/") == "/models": + self.send_response(302) + self.send_header("Location", type(self).redirect_to) + self.end_headers() + else: + _RedirectingHandler.received_headers = dict(self.headers) + body = json.dumps({"data": [{"id": "redirected-model"}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + +class TestFetchModelsRedirectCredentialStripping: + """Credential headers must not follow a redirect outside the original origin.""" + + def _run(self, redirect_to): + """redirect_to is a callable (first_port, second_port) -> Location URL.""" + _RedirectingHandler.received_headers = {} + server = HTTPServer(("127.0.0.1", 0), _RedirectingHandler) + second_server = HTTPServer(("127.0.0.1", 0), _RedirectingHandler) + port = server.server_address[1] + second_port = second_server.server_address[1] + _RedirectingHandler.redirect_to = redirect_to(port, second_port) + Thread(target=server.serve_forever, daemon=True).start() + Thread(target=second_server.serve_forever, daemon=True).start() + try: + profile = ProviderProfile( + name="test", + base_url=f"http://127.0.0.1:{port}", + default_headers={"x-api-key": "default-header-secret"}, + ) + result = profile.fetch_models(api_key="bearer-secret") + finally: + server.shutdown() + second_server.shutdown() + headers = {k.lower(): v for k, v in _RedirectingHandler.received_headers.items()} + return result, headers + + def test_cross_host_redirect_strips_credentials(self): + result, headers = self._run( + lambda port, _: f"http://localhost:{port}/redirected" + ) + assert result == ["redirected-model"] # fetch itself still works + assert "authorization" not in headers + assert "x-api-key" not in headers + + def test_same_host_different_port_redirect_strips_credentials(self): + """A different port is a different origin — it can be a different service.""" + result, headers = self._run( + lambda _, second_port: f"http://127.0.0.1:{second_port}/redirected" + ) + assert result == ["redirected-model"] + assert "authorization" not in headers + assert "x-api-key" not in headers + + def test_same_origin_redirect_keeps_credentials(self): + result, headers = self._run( + lambda port, _: f"http://127.0.0.1:{port}/redirected" + ) + assert result == ["redirected-model"] + assert headers.get("authorization") == "Bearer bearer-secret" + assert headers.get("x-api-key") == "default-header-secret" + + class TestModelPickerBaseUrlIntegration: """The /model picker path should pass model.base_url to fetch_models.""" diff --git a/tests/providers/test_plugin_discovery.py b/tests/providers/test_plugin_discovery.py index fba5a02df11..ee62cccd5e4 100644 --- a/tests/providers/test_plugin_discovery.py +++ b/tests/providers/test_plugin_discovery.py @@ -68,7 +68,7 @@ def test_all_profiles_register(): # Spot-check representative providers from different categories for required in ( "openrouter", "anthropic", "custom", "bedrock", "openai-codex", - "minimax-oauth", "gmi", "xiaomi", "alibaba-coding-plan", + "minimax-oauth", "gmi", "xiaomi", "alibaba-coding-plan", "fireworks", ): assert required in names, f"Missing profile: {required}" diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index 3eb23403048..bd450cb5600 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -314,12 +314,12 @@ class TestOpenRouterProfile: Covers the full real config range produced by ``hermes_constants.parse_reasoning_effort`` — - ``VALID_REASONING_EFFORTS = (minimal, low, medium, high, xhigh)``. + ``VALID_REASONING_EFFORTS`` (including max and ultra). """ p = get_provider_profile("openrouter") model = "anthropic/claude-fable-5" assert self._is_mandatory(model) # fixture really is mandatory - for effort in ("minimal", "low", "medium", "high", "xhigh"): + for effort in ("minimal", "low", "medium", "high", "xhigh", "max", "ultra"): eb, tl = p.build_api_kwargs_extras( reasoning_config={"enabled": True, "effort": effort}, supports_reasoning=True, @@ -342,14 +342,12 @@ class TestOpenRouterProfile: def test_mandatory_anthropic_verbosity_is_value_agnostic_passthrough(self): """The mapping passes the effort value through verbatim — it must NOT - clamp or whitelist. ``xhigh`` is a real config value; ``max`` is not - producible by ``parse_reasoning_effort`` today but OpenRouter accepts it - for Claude (live-proven in #43432), so a forward value must survive + clamp or whitelist. Extended values must survive rather than be silently dropped. The OpenAI SDK type only literals ``low|medium|high`` but it's a TypedDict (no runtime validation), so the extended scale reaches the wire untouched.""" p = get_provider_profile("openrouter") - for effort in ("xhigh", "max"): + for effort in ("xhigh", "max", "ultra"): _, tl = p.build_api_kwargs_extras( reasoning_config={"enabled": True, "effort": effort}, supports_reasoning=True, @@ -414,6 +412,19 @@ class TestNousProfile: body = p.build_extra_body() assert body["tags"] == nous_portal_tags() + def test_extra_body_with_provider_preferences(self): + from agent.portal_tags import nous_portal_tags + + p = get_provider_profile("nous") + assert p is not None + preferences = {"only": ["deepseek"], "ignore": ["deepinfra"]} + body = p.build_extra_body(provider_preferences=preferences) + + assert body == { + "tags": nous_portal_tags(), + "provider": preferences, + } + def test_auth_type(self): p = get_provider_profile("nous") assert p.auth_type == "oauth_device_code" @@ -464,6 +475,69 @@ class TestQwenProfile: assert isinstance(result[1]["content"], list) assert result[1]["content"][0]["text"] == "hello" + def test_prepare_messages_copy_on_write(self): + p = get_provider_profile("qwen-oauth") + system_part = {"type": "text", "text": "Be helpful"} + msgs = [ + {"role": "system", "content": [system_part]}, + {"role": "assistant", "content": [{"type": "text", "text": "unchanged"}]}, + {"role": "user", "content": ["hello"]}, + ] + + result = p.prepare_messages(msgs) + + assert result is not msgs + assert result[0] is not msgs[0] + assert result[0]["content"] is not msgs[0]["content"] + assert result[0]["content"][0] is not system_part + assert result[0]["content"][0]["cache_control"] == {"type": "ephemeral"} + assert "cache_control" not in system_part + assert result[1] is msgs[1] + assert result[2] is not msgs[2] + assert result[2]["content"] == [{"type": "text", "text": "hello"}] + assert msgs[2]["content"] == ["hello"] + + def test_prepare_messages_does_not_poison_strict_provider_history(self): + qwen = get_provider_profile("qwen-oauth") + msgs = [ + {"role": "system", "content": [{"type": "text", "text": "Be helpful"}]}, + {"role": "user", "content": "hello"}, + ] + + qwen_result = qwen.prepare_messages(msgs) + + assert qwen_result[0]["content"][0]["cache_control"] == {"type": "ephemeral"} + assert "cache_control" not in msgs[0]["content"][0] + assert msgs[1]["content"] == "hello" + + def test_prepare_messages_protects_nested_image_url_retry_mutation(self): + qwen = get_provider_profile("qwen-oauth") + image_url = {"url": "data:image/png;base64,original"} + msgs = [ + {"role": "system", "content": "Be helpful"}, + { + "role": "user", + "content": [ + {"type": "text", "text": "see image"}, + {"type": "image_url", "image_url": image_url}, + ], + }, + ] + + qwen_result = qwen.prepare_messages(msgs) + + assert qwen_result[1] is not msgs[1] + assert qwen_result[1]["content"] is not msgs[1]["content"] + assert qwen_result[1]["content"][1] is not msgs[1]["content"][1] + assert qwen_result[1]["content"][1]["image_url"] is not image_url + + qwen_result[1]["content"][1]["image_url"]["url"] = ( + "data:image/png;base64,shrunk" + ) + assert msgs[1]["content"][1]["image_url"]["url"] == ( + "data:image/png;base64,original" + ) + def test_metadata_top_level(self): p = get_provider_profile("qwen-oauth") meta = {"sessionId": "s123", "promptId": "p456"} diff --git a/tests/providers/test_transport_parity.py b/tests/providers/test_transport_parity.py index dec63edf8eb..b77526917c9 100644 --- a/tests/providers/test_transport_parity.py +++ b/tests/providers/test_transport_parity.py @@ -208,6 +208,21 @@ class TestNousParity: ) assert kw["extra_body"]["tags"] == nous_portal_tags() + def test_provider_preferences(self, transport): + preferences = { + "only": ["deepseek"], + "ignore": ["deepinfra"], + "sort": "throughput", + } + kw = transport.build_kwargs( + model="deepseek/deepseek-v4-flash", + messages=_simple_messages(), + tools=None, + provider_profile=get_provider_profile("nous"), + provider_preferences=preferences, + ) + assert kw["extra_body"]["provider"] == preferences + def test_reasoning_omitted_when_disabled(self, transport): """Nous special case: reasoning omitted entirely when disabled.""" kw = transport.build_kwargs( diff --git a/tests/run_agent/test_agent_guardrails.py b/tests/run_agent/test_agent_guardrails.py index eb89cdda9c0..bcec4e3d7c8 100644 --- a/tests/run_agent/test_agent_guardrails.py +++ b/tests/run_agent/test_agent_guardrails.py @@ -8,10 +8,26 @@ Covers three static methods on AIAgent (inspired by PR #1321 — @alireza78a): import types -from run_agent import AIAgent -from tools.delegate_tool import _get_max_concurrent_children +import pytest -MAX_CONCURRENT_CHILDREN = _get_max_concurrent_children() +from run_agent import AIAgent + +# Pin the concurrency limit instead of reading the runtime config. +# _cap_delegate_task_calls() resolves _get_max_concurrent_children() at CALL +# time (inside a per-test hermetic HERMES_HOME), but this module previously +# froze the value at IMPORT time — before the hermetic fixture ran — so a +# developer machine with delegation.max_concurrent_children in the real +# ~/.hermes/config.yaml saw a different limit at import vs call and the +# truncation tests failed locally while passing on CI. +MAX_CONCURRENT_CHILDREN = 3 + + +@pytest.fixture(autouse=True) +def _pin_max_concurrent_children(monkeypatch): + monkeypatch.setattr( + "tools.delegate_tool._get_max_concurrent_children", + lambda: MAX_CONCURRENT_CHILDREN, + ) # --------------------------------------------------------------------------- diff --git a/tests/run_agent/test_anthropic_prompt_cache_policy.py b/tests/run_agent/test_anthropic_prompt_cache_policy.py index ba6e54f0372..679a9219fbe 100644 --- a/tests/run_agent/test_anthropic_prompt_cache_policy.py +++ b/tests/run_agent/test_anthropic_prompt_cache_policy.py @@ -75,6 +75,57 @@ class TestOpenRouter: assert agent._anthropic_prompt_cache_policy() == (False, False) +class TestKimiMoonshotOnOpenRouter: + """Kimi/Moonshot on OpenRouter honour envelope-layout cache_control (#25970).""" + + def test_kimi_k26_on_openrouter_caches_with_envelope_layout(self): + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="moonshotai/kimi-k2.6", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_moonshot_v1_on_openrouter_caches_with_envelope_layout(self): + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="moonshotai/moonshot-v1-8k", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_kimi_on_nous_portal_caches_with_envelope_layout(self): + agent = _make_agent( + provider="nous", + base_url="https://api.nousresearch.com/v1", + api_mode="chat_completions", + model="moonshotai/kimi-k2.6", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_kimi_bare_release_slug_on_openrouter_caches(self): + """Bare release slugs (k2-thinking) lack the 'kimi'/'moonshot' substring; + the canonical family matcher must still catch them.""" + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="k2-thinking", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_kimi_on_non_openrouter_host_does_not_cache(self): + agent = _make_agent( + provider="custom", + base_url="https://api.moonshot.cn/v1", + api_mode="chat_completions", + model="moonshotai/kimi-k2.6", + ) + assert agent._anthropic_prompt_cache_policy() == (False, False) + + class TestThirdPartyAnthropicGateway: """Third-party gateways speaking the Anthropic protocol (MiniMax, Zhipu GLM, LiteLLM).""" diff --git a/tests/run_agent/test_background_review_cost_controls.py b/tests/run_agent/test_background_review_cost_controls.py index 5ca47b2a0f9..7e5da983faf 100644 --- a/tests/run_agent/test_background_review_cost_controls.py +++ b/tests/run_agent/test_background_review_cost_controls.py @@ -8,6 +8,7 @@ Covers the two behaviors this change adds: Pure-function / config-driven; no live model calls. """ +from typing import Any from unittest.mock import patch from agent import background_review as br @@ -28,6 +29,9 @@ class _FakeAgent: def __init__(self, provider="openai-codex", model="gpt-5.5"): self.provider = provider self.model = model + self._credential_pool: Any = None + self.request_overrides = {} + self.max_tokens: int | None = None def _current_main_runtime(self): return { @@ -56,6 +60,9 @@ def test_routing_to_different_model_marks_routed_and_resolves_credentials(): fake_rp = { "provider": "openrouter", "api_key": "or-key", "base_url": "https://openrouter.ai/api/v1", "api_mode": "chat_completions", + "credential_pool": "routed-pool", + "request_overrides": {"extra_body": {"store": False}}, + "max_output_tokens": 2048, } with patch("hermes_cli.config.load_config", return_value=cfg), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_rp): @@ -64,6 +71,21 @@ def test_routing_to_different_model_marks_routed_and_resolves_credentials(): assert rt["provider"] == "openrouter" assert rt["model"] == "google/gemini-3-flash-preview" assert rt["api_key"] == "or-key" + assert rt["credential_pool"] == "routed-pool" + assert rt["request_overrides"] == {"extra_body": {"store": False}} + assert rt["max_tokens"] == 2048 + + +def test_unrouted_runtime_keeps_parent_pool_and_overrides(): + agent = _FakeAgent() + agent._credential_pool = "parent-pool" + agent.request_overrides = {"service_tier": "priority"} + agent.max_tokens = 4096 + with patch("hermes_cli.config.load_config", return_value={}): + rt = br._resolve_review_runtime(agent) + assert rt["credential_pool"] == "parent-pool" + assert rt["request_overrides"] == {"service_tier": "priority"} + assert rt["max_tokens"] == 4096 def test_routing_same_model_as_parent_is_not_routed(): diff --git a/tests/run_agent/test_codex_app_server_compaction.py b/tests/run_agent/test_codex_app_server_compaction.py index 4bd5e8431dd..93bbe0eadf2 100644 --- a/tests/run_agent/test_codex_app_server_compaction.py +++ b/tests/run_agent/test_codex_app_server_compaction.py @@ -102,6 +102,8 @@ def test_codex_app_server_manual_compression_routes_to_codex_thread(): assert agent._codex_session.calls == 1 assert agent.context_compressor.compression_count == 1 assert agent.context_compressor.last_compression_rough_tokens == 100000 + # This minimal fake compressor does not implement update_from_response(), + # so the runtime preserves its existing pending-usage bookkeeping here. assert agent.context_compressor.last_prompt_tokens == -1 assert agent.context_compressor.last_completion_tokens == 0 assert agent.context_compressor.awaiting_real_usage_after_compression is True @@ -192,3 +194,31 @@ def test_codex_app_server_native_compaction_notice_emits_status_and_event(): }, ) ] + + +def test_codex_native_boundary_clears_stale_hermes_fallback_streak(): + from unittest.mock import patch + + from agent.context_compressor import ContextCompressor + + with patch( + "agent.context_compressor.get_model_context_length", + return_value=100_000, + ): + compressor = ContextCompressor(model="test-model", quiet_mode=True) + compressor._fallback_compression_streak = 1 + compressor._last_summary_fallback_used = True + + agent = DummyAgent( + TurnResult(thread_id="thread-1", turn_id="normal-turn-1") + ) + agent.context_compressor = compressor + turn = TurnResult( + thread_id="thread-1", + turn_id="normal-turn-1", + compacted=True, + ) + + assert _record_codex_app_server_compaction(agent, turn) is True + assert compressor._fallback_compression_streak == 0 + assert compressor._verify_compaction_cleared_threshold is True diff --git a/tests/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py index df16807ca84..c88d45c483a 100644 --- a/tests/run_agent/test_codex_app_server_integration.py +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -143,6 +143,13 @@ class TestRunConversationCodexPath: turn_id="turn-compact-1", thread_id="thread-compact-1", compacted=True, + token_usage_last={ + "totalTokens": 300_000, + "inputTokens": 300_000, + "cachedInputTokens": 0, + "outputTokens": 0, + "reasoningOutputTokens": 0, + }, ) monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) @@ -157,8 +164,11 @@ class TestRunConversationCodexPath: assert result["completed"] is True assert agent.context_compressor.compression_count == 1 - assert agent.context_compressor.last_prompt_tokens == -1 - assert agent.context_compressor.awaiting_real_usage_after_compression is True + # A compacted turn with real usage is judged against that same real + # prompt count, exactly like a normal completed compression boundary. + assert agent.context_compressor.last_prompt_tokens == 300_000 + assert agent.context_compressor.awaiting_real_usage_after_compression is False + assert agent.context_compressor._ineffective_compression_count == 1 assert events == [ ( "session:compress", diff --git a/tests/run_agent/test_fireworks_live.py b/tests/run_agent/test_fireworks_live.py new file mode 100644 index 00000000000..9e2944ef98c --- /dev/null +++ b/tests/run_agent/test_fireworks_live.py @@ -0,0 +1,64 @@ +"""Live Fireworks smoke test — exercises the Hermes runtime, not a raw SDK client. + +Opt-in only: + HERMES_LIVE_TESTS=1 FIREWORKS_API_KEY=fw_... \\ + pytest tests/run_agent/test_fireworks_live.py -q + +Unlike a bare OpenAI() client pointed at the endpoint, this drives Hermes' +own provider resolution — ``resolve_provider_client('fireworks')`` — so it +verifies the auth/config/base-URL/aux-model wiring that the +bundled provider actually ships, then makes a real call through that client. +""" + +from __future__ import annotations + +import os + +import pytest + +LIVE = os.environ.get("HERMES_LIVE_TESTS") == "1" +FIREWORKS_KEY = os.environ.get("FIREWORKS_API_KEY", "") + +pytestmark = [ + pytest.mark.skipif(not LIVE, reason="live-only: set HERMES_LIVE_TESTS=1"), + pytest.mark.skipif(not FIREWORKS_KEY, reason="FIREWORKS_API_KEY not configured"), + pytest.mark.integration, +] + + +def _resolve_runtime_client(provider="fireworks"): + """Build the Fireworks client the way the Hermes runtime does.""" + from agent.auxiliary_client import resolve_provider_client + + client, model = resolve_provider_client(provider) + assert client is not None, "Hermes failed to build a Fireworks client" + return client, model + + +def test_hermes_wires_fireworks_client(): + """The runtime resolves a Fireworks client pointed at the right endpoint + with the partner-attribution headers applied — no network required.""" + client, model = _resolve_runtime_client() + assert "api.fireworks.ai" in str(client.base_url) + # Default aux model must be a PAYG /models/ id (works with an fw_ key). + assert model.startswith("accounts/fireworks/models/") + + +def test_fireworks_basic_chat_through_runtime(): + """A single-turn completion via the Hermes-resolved client returns text.""" + client, model = _resolve_runtime_client() + + response = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": "Say exactly the word 'pong' and nothing else."}], + timeout=60, + ) + + content = response.choices[0].message.content + assert content and "pong" in content.lower() + + +def test_fireworks_alias_resolves_through_runtime(): + """The 'fw' alias resolves to the same Fireworks client via the runtime.""" + client, _ = _resolve_runtime_client("fw") + assert "api.fireworks.ai" in str(client.base_url) diff --git a/tests/run_agent/test_malformed_tool_arguments.py b/tests/run_agent/test_malformed_tool_arguments.py new file mode 100644 index 00000000000..6736182d304 --- /dev/null +++ b/tests/run_agent/test_malformed_tool_arguments.py @@ -0,0 +1,98 @@ +"""Malformed model tool arguments are rejected at the dispatch boundary.""" + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent + + +def _make_agent() -> AIAgent: + tool_defs = [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "search", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + with ( + patch("run_agent.get_tool_definitions", return_value=tool_defs), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("hermes_cli.config.load_config", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.client = MagicMock() + agent.tool_delay = 0 + agent._flush_messages_to_session_db = MagicMock() + return agent + + +def _tool_call(call_id: str, arguments: str): + return SimpleNamespace( + id=call_id, + type="function", + function=SimpleNamespace(name="web_search", arguments=arguments), + ) + + +@pytest.mark.parametrize("dispatch_mode", ["sequential", "concurrent"]) +@pytest.mark.parametrize( + "bad_arguments", + [ + pytest.param("not-json", id="malformed-json"), + pytest.param('"scalar"', id="scalar"), + pytest.param("[]", id="list"), + pytest.param("", id="empty"), + pytest.param('{"query": "cut off', id="truncated"), + ], +) +def test_malformed_arguments_are_rejected_without_blocking_valid_sibling( + dispatch_mode: str, + bad_arguments: str, +): + agent = _make_agent() + assistant_message = SimpleNamespace( + content="", + tool_calls=[ + _tool_call("call-bad", bad_arguments), + _tool_call("call-good", '{"query": "valid"}'), + ], + ) + messages = [] + executed = [] + + def fake_dispatch(name, args, task_id, *positional, **kwargs): + call_id = kwargs.get("tool_call_id") or (positional[0] if positional else None) + executed.append((name, args, call_id)) + return json.dumps({"ok": args["query"]}) + + with ( + patch("run_agent.handle_function_call", side_effect=fake_dispatch), + patch.object(agent, "_invoke_tool", side_effect=fake_dispatch), + patch( + "agent.tool_executor.maybe_persist_tool_result", + side_effect=lambda **kwargs: kwargs["content"], + ), + ): + execute = getattr(agent, f"_execute_tool_calls_{dispatch_mode}") + execute(assistant_message, messages, "task-1") + + assert executed == [("web_search", {"query": "valid"}, "call-good")] + assert [message["tool_call_id"] for message in messages] == ["call-bad", "call-good"] + assert len([message for message in messages if message["tool_call_id"] == "call-bad"]) == 1 + + assert '"error": "Invalid tool arguments"' in messages[0]["content"] + assert "JSON object" in messages[0]["content"] + assert json.loads(messages[1]["content"]) == {"ok": "valid"} diff --git a/tests/run_agent/test_primary_runtime_restore.py b/tests/run_agent/test_primary_runtime_restore.py index d1ac56dca6b..0d732277e97 100644 --- a/tests/run_agent/test_primary_runtime_restore.py +++ b/tests/run_agent/test_primary_runtime_restore.py @@ -286,15 +286,47 @@ class TestRestorePrimaryRuntime: agent._credential_pool = _DeepseekPool() agent._swap_credential = MagicMock() - with patch("run_agent.OpenAI", return_value=MagicMock()): + primary_pool = MagicMock() + primary_pool.provider = primary_provider + primary_pool.has_available.return_value = False + with ( + patch("run_agent.OpenAI", return_value=MagicMock()), + patch("agent.credential_pool.load_pool", return_value=primary_pool) as load_pool, + ): result = agent._restore_primary_runtime() assert result is True assert agent.provider == primary_provider assert agent.base_url == primary_base_url assert "deepseek" not in str(agent.base_url) + assert agent._credential_pool is primary_pool + load_pool.assert_called_once_with(primary_provider) agent._swap_credential.assert_not_called() + def test_restore_clears_fallback_pool_when_primary_pool_reload_fails(self): + """A fallback pool must never remain attached to the restored primary.""" + agent = _make_agent( + provider="openai-api", + base_url="https://api.openai.com/v1", + ) + agent._fallback_activated = True + fallback_pool = MagicMock() + fallback_pool.provider = "deepseek" + agent._credential_pool = fallback_pool + + with ( + patch("run_agent.OpenAI", return_value=MagicMock()), + patch( + "agent.credential_pool.load_pool", + side_effect=RuntimeError("auth store unavailable"), + ), + ): + result = agent._restore_primary_runtime() + + assert result is True + assert agent.provider == "openai-api" + assert agent._credential_pool is None + def test_restore_swaps_matching_custom_pool_entry(self): """Custom primary + custom: entry whose base_url resolves to the SAME custom key must swap (legitimate same-endpoint rotation).""" diff --git a/tests/run_agent/test_retry_status_buffer.py b/tests/run_agent/test_retry_status_buffer.py index 221c10c7596..bf116f177b7 100644 --- a/tests/run_agent/test_retry_status_buffer.py +++ b/tests/run_agent/test_retry_status_buffer.py @@ -135,6 +135,86 @@ def test_mixed_kinds_replay_through_correct_channels(): assert warns == ["warn-1"] +def test_pending_fallback_notice_emitted_once_on_success(): + """On successful recovery the one-shot fallback notice is surfaced even + though the noisy retry buffer is dropped.""" + agent = _make_bare_agent() + emitted = [] + agent._emit_status = lambda msg: emitted.append(msg) + + # Simulate try_activate_fallback: buffer the noisy switch line AND record + # the durable one-shot notice. + agent._buffer_status("🔄 Primary model failed — switching to fallback: m2 via p2") + agent._pending_fallback_notice = "🔄 Switched to fallback model: m1 via p1 → m2 via p2" + + # Success path order: emit pending notice, then drop the buffer. + agent._emit_pending_fallback_notice() + agent._clear_status_buffer() + + # The durable notice was shown exactly once; the buffered retry noise was + # silently dropped. + assert emitted == ["🔄 Switched to fallback model: m1 via p1 → m2 via p2"] + assert agent._retry_status_buffer == [] + # Notice is cleared so it cannot re-emit on a later turn. + assert agent._pending_fallback_notice is None + + # A second success path with no new fallback emits nothing. + agent._emit_pending_fallback_notice() + assert emitted == ["🔄 Switched to fallback model: m1 via p1 → m2 via p2"] + + +def test_pending_fallback_notice_noop_when_unset(): + """No fallback this turn → no notice emitted on the success path.""" + agent = _make_bare_agent() + emitted = [] + agent._emit_status = lambda msg: emitted.append(msg) + + # No _pending_fallback_notice attribute set at all. + agent._emit_pending_fallback_notice() + assert emitted == [] + + +def test_flush_discards_pending_fallback_notice(): + """On terminal failure the flushed buffer already carries the switch line, + so the one-shot notice is discarded to avoid a stale duplicate later.""" + agent = _make_bare_agent() + emitted = [] + agent._emit_status = lambda msg: emitted.append(msg) + + agent._buffer_status("🔄 Primary model failed — switching to fallback: m2 via p2") + agent._pending_fallback_notice = "🔄 Switched to fallback model: m1 via p1 → m2 via p2" + + # Terminal failure flushes the buffered trace... + agent._flush_status_buffer() + assert emitted == ["🔄 Primary model failed — switching to fallback: m2 via p2"] + # ...and discards the pending notice so it won't re-emit on a later turn. + assert agent._pending_fallback_notice is None + + emitted.clear() + agent._emit_pending_fallback_notice() + assert emitted == [] + + +def test_pending_fallback_notice_survives_emit_callback_error(): + """A failing status callback must not leave the notice set for a stale + re-emit, and must not raise.""" + agent = _make_bare_agent() + seen = [] + + def boom(msg): + seen.append(msg) + raise RuntimeError("simulated callback failure") + + agent._emit_status = boom + agent._pending_fallback_notice = "🔄 Switched to fallback model: m1 via p1 → m2 via p2" + + # Should not raise. + agent._emit_pending_fallback_notice() + # Attempt was made and the notice is cleared regardless. + assert seen == ["🔄 Switched to fallback model: m1 via p1 → m2 via p2"] + assert agent._pending_fallback_notice is None + + def test_flush_swallows_callback_exceptions(): agent = _make_bare_agent() seen = [] diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 171a7c11255..afb45a53a71 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2333,7 +2333,7 @@ class TestExecuteToolCalls: or "interrupted" in messages[0]["content"].lower() ) - def test_invalid_json_args_defaults_empty(self, agent): + def test_invalid_json_args_are_rejected_without_dispatch(self, agent): tc = _mock_tool_call( name="web_search", arguments="not valid json", call_id="c1" ) @@ -2341,13 +2341,12 @@ class TestExecuteToolCalls: messages = [] with patch("run_agent.handle_function_call", return_value="ok") as mock_hfc: agent._execute_tool_calls(mock_msg, messages, "task-1") - # Invalid JSON args should fall back to empty dict - args, kwargs = mock_hfc.call_args - assert args[:3] == ("web_search", {}, "task-1") - assert set(kwargs.get("enabled_tools", [])) == agent.valid_tool_names + mock_hfc.assert_not_called() assert len(messages) == 1 assert messages[0]["role"] == "tool" assert messages[0]["tool_call_id"] == "c1" + assert "valid json object" in messages[0]["content"].lower() + assert "tool was not executed" in messages[0]["content"].lower() def test_result_truncation_over_100k(self, agent, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) @@ -2776,6 +2775,7 @@ class TestConcurrentToolExecution: assert "fast-result" in messages[0]["content"] assert messages[1]["tool_call_id"] == "c2" assert "timed out after" in messages[1]["content"] + assert messages[1]["effect_disposition"] == "unknown" assert [batch[-1]["tool_call_id"] for batch in flushed] == ["c1", "c2"] assert "fast-result" in flushed[0][-1]["content"] assert "timed out after" in flushed[1][-1]["content"] @@ -3789,6 +3789,48 @@ class TestHandleMaxIterations: kwargs = agent.client.chat.completions.create.call_args.kwargs assert kwargs["extra_body"]["provider"]["only"] == ["Anthropic"] + def test_summary_keeps_provider_preferences_for_nous(self, agent): + agent.base_url = "https://proxy.example.com/v1" + agent._base_url_lower = agent.base_url.lower() + agent.provider = "nous" + agent.providers_allowed = ["deepseek"] + agent.providers_ignored = ["deepinfra"] + agent.provider_sort = "throughput" + agent.provider_require_parameters = True + agent.provider_data_collection = "deny" + agent.client.chat.completions.create.return_value = _mock_response(content="Summary") + agent._cached_system_prompt = "You are helpful." + + result = agent._handle_max_iterations([{"role": "user", "content": "do stuff"}], 60) + + assert result == "Summary" + kwargs = agent.client.chat.completions.create.call_args.kwargs + from agent.portal_tags import nous_portal_tags + + assert kwargs["extra_body"]["tags"] == nous_portal_tags() + assert kwargs["extra_body"]["provider"] == { + "only": ["deepseek"], + "ignore": ["deepinfra"], + "sort": "throughput", + "require_parameters": True, + "data_collection": "deny", + } + + def test_summary_keeps_nous_profile_body_without_routing_preferences(self, agent): + agent.base_url = "https://proxy.example.com/v1" + agent._base_url_lower = agent.base_url.lower() + agent.provider = "nous" + agent.client.chat.completions.create.return_value = _mock_response(content="Summary") + agent._cached_system_prompt = "You are helpful." + + result = agent._handle_max_iterations([{"role": "user", "content": "do stuff"}], 60) + + assert result == "Summary" + kwargs = agent.client.chat.completions.create.call_args.kwargs + from agent.portal_tags import nous_portal_tags + + assert kwargs["extra_body"] == {"tags": nous_portal_tags()} + def test_summary_drops_invalid_provider_sort(self, agent): agent.base_url = "https://openrouter.ai/api/v1" agent._base_url_lower = agent.base_url.lower() @@ -5313,6 +5355,281 @@ class TestRunConversation: "_record_task_failure should not be called outside kanban mode" ) + # ── Output-cap retry: safe_out uses provider available_out + request estimate ── + + def test_output_cap_retry_uses_provider_available_out(self, agent): + """run_conversation retries an output-cap error with max_tokens <= + available_out - 64, and does NOT halve context_length or trigger + compression. + """ + self._setup_agent(agent) + agent.api_mode = "chat_completions" + agent.provider = "openrouter" + agent.model = "some/model" + agent.max_tokens = 65_536 + agent.compression_enabled = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.should_compress = MagicMock(return_value=False) + + error_msg = ( + "max_tokens: 65536 > context_window: 200000 " + "- input_tokens: 199000 = available_tokens: 1000" + ) + exc = Exception(error_msg) + exc.status_code = 400 + exc.code = 400 + + ok_resp = _mock_response(content="done", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [exc, ok_resp] + + mock_compress = MagicMock() + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent.context_compressor, "update_model"), + patch.object(agent, "_compress_context", mock_compress), + ): + result = agent.run_conversation("hello") + + second_call = agent.client.chat.completions.create.call_args_list[1].kwargs + assert result["completed"] is True + assert second_call["max_tokens"] <= 936 + assert agent.context_compressor.context_length == 200_000 + mock_compress.assert_not_called() + + def test_output_cap_retry_with_large_api_only_content(self, agent): + """When a large system prompt makes api_messages huge while persisted + messages stay tiny, the retry cap must still respect provider + available_tokens — not blow up to the full context window. + """ + self._setup_agent(agent) + agent.api_mode = "chat_completions" + agent.provider = "openrouter" + agent.model = "some/model" + agent.max_tokens = 65_536 + agent.compression_enabled = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.should_compress = MagicMock(return_value=False) + + # Huge API-only system prompt; persisted messages are tiny. + agent._cached_system_prompt = "S" * 796_000 + + error_msg = ( + "max_tokens: 65536 > context_window: 200000 " + "- input_tokens: 199000 = available_tokens: 1000" + ) + exc = Exception(error_msg) + exc.status_code = 400 + exc.code = 400 + + ok_resp = _mock_response(content="done", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [exc, ok_resp] + + mock_compress = MagicMock() + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent.context_compressor, "update_model"), + patch.object(agent, "_compress_context", mock_compress), + ): + result = agent.run_conversation("hello") + + second_call = agent.client.chat.completions.create.call_args_list[1].kwargs + assert result["completed"] is True + # The current branch (messages-only estimate) would send max_tokens + # near 199927 — this test fails on it. + assert second_call["max_tokens"] <= 936 + assert agent.context_compressor.context_length == 200_000 + mock_compress.assert_not_called() + + def test_output_cap_retry_request_pressure_lower_bound(self, agent): + """When the provider reports a large available_tokens but local request + pressure leaves less room, the retry cap is the smaller of the two. + """ + self._setup_agent(agent) + agent.api_mode = "chat_completions" + agent.provider = "openrouter" + agent.model = "some/model" + agent.max_tokens = 65_536 + agent.compression_enabled = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.should_compress = MagicMock(return_value=False) + + # A large API-only system prompt so the local estimate is the binding + # constraint, not the provider's available_tokens. + agent._cached_system_prompt = "S" * 796_000 + + error_msg = ( + "max_tokens: 65536 > context_window: 200000 " + "- input_tokens: 190000 = available_tokens: 50000" + ) + exc = Exception(error_msg) + exc.status_code = 400 + exc.code = 400 + + ok_resp = _mock_response(content="done", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [exc, ok_resp] + + mock_compress = MagicMock() + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent.context_compressor, "update_model"), + patch.object(agent, "_compress_context", mock_compress), + ): + result = agent.run_conversation("hello") + + first_call = agent.client.chat.completions.create.call_args_list[0].kwargs + second_call = agent.client.chat.completions.create.call_args_list[1].kwargs + assert result["completed"] is True + + # Verify the local estimate is actually the lower bound. + from agent.model_metadata import estimate_request_tokens_rough + estimated_request = estimate_request_tokens_rough( + first_call["messages"], tools=agent.tools or None, + ) + local_available = 200_000 - estimated_request + expected_cap = max(1, min(50_000, local_available) - 64) + assert local_available < 50_000 + assert second_call["max_tokens"] == expected_cap + assert agent.context_compressor.context_length == 200_000 + mock_compress.assert_not_called() + + def test_output_cap_retry_safety_floor_at_one(self, agent): + """When provider available_tokens is 1, the retry cap is floored at 1.""" + self._setup_agent(agent) + agent.api_mode = "chat_completions" + agent.provider = "openrouter" + agent.model = "some/model" + agent.max_tokens = 65_536 + agent.compression_enabled = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.should_compress = MagicMock(return_value=False) + + error_msg = ( + "max_tokens: 65536 > context_window: 200000 " + "- input_tokens: 199999 = available_tokens: 1" + ) + exc = Exception(error_msg) + exc.status_code = 400 + exc.code = 400 + + ok_resp = _mock_response(content="done", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [exc, ok_resp] + + mock_compress = MagicMock() + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent.context_compressor, "update_model"), + patch.object(agent, "_compress_context", mock_compress), + ): + result = agent.run_conversation("hello") + + second_call = agent.client.chat.completions.create.call_args_list[1].kwargs + assert result["completed"] is True + assert second_call["max_tokens"] == 1 + assert agent.context_compressor.context_length == 200_000 + mock_compress.assert_not_called() + + def test_output_cap_retry_with_compression_disabled(self, agent): + """Output-cap retry must still work when compression.enabled is false. + The recovery is a max_tokens-only retry — it does not require compression, + so the compression-disabled guard must not block it. + """ + self._setup_agent(agent) + agent.api_mode = "chat_completions" + agent.provider = "openrouter" + agent.model = "some/model" + agent.max_tokens = 65_536 + agent.compression_enabled = False + agent.context_compressor.context_length = 200_000 + agent.context_compressor.should_compress = MagicMock(return_value=False) + + error_msg = ( + "max_tokens: 65536 > context_window: 200000 " + "- input_tokens: 199000 = available_tokens: 1000" + ) + exc = Exception(error_msg) + exc.status_code = 400 + exc.code = 400 + + ok_resp = _mock_response(content="done", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [exc, ok_resp] + + mock_compress = MagicMock() + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent.context_compressor, "update_model"), + patch.object(agent, "_compress_context", mock_compress), + ): + result = agent.run_conversation("hello") + + # Two API calls: the failed one and the retried one. + assert len(agent.client.chat.completions.create.call_args_list) == 2 + second_call = agent.client.chat.completions.create.call_args_list[1].kwargs + assert result["completed"] is True + assert result.get("compaction_disabled") is None + assert second_call["max_tokens"] <= 936 + assert agent.context_compressor.context_length == 200_000 + mock_compress.assert_not_called() + + def test_output_cap_retry_with_compression_disabled_vllm_format(self, agent): + """vLLM/LM Studio error messages contain 'prompt contains ... input + tokens' which is_output_cap_error() treats as an input-overflow signal + (returns False). But parse_available_output_tokens_from_error() CAN + extract a valid available_tokens from them. The compression-disabled + guard must exempt these too — otherwise users on vLLM/LM Studio with + compression off get a terminal failure instead of a max-tokens retry. + """ + self._setup_agent(agent) + agent.api_mode = "chat_completions" + agent.provider = "openrouter" + agent.model = "some/model" + agent.max_tokens = 65_536 + agent.compression_enabled = False + agent.context_compressor.context_length = 131_072 + agent.context_compressor.should_compress = MagicMock(return_value=False) + + # vLLM-format error (from tests/test_output_cap_parsing.py) + error_msg = ( + "This model's maximum context length is 131072 tokens. " + "However, you requested 1024 output tokens and your prompt " + "contains at least 65537 input tokens, for a total of at least " + "66561 tokens." + ) + exc = Exception(error_msg) + exc.status_code = 400 + exc.code = 400 + + ok_resp = _mock_response(content="done", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [exc, ok_resp] + + mock_compress = MagicMock() + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent.context_compressor, "update_model"), + patch.object(agent, "_compress_context", mock_compress), + ): + result = agent.run_conversation("hello") + + assert len(agent.client.chat.completions.create.call_args_list) == 2 + second_call = agent.client.chat.completions.create.call_args_list[1].kwargs + assert result["completed"] is True + assert result.get("compaction_disabled") is None + # parse_available_output_tokens_from_error returns 65535 for this message + assert second_call["max_tokens"] <= 65471 # 65535 - 64 + assert agent.context_compressor.context_length == 131_072 + mock_compress.assert_not_called() + class TestHookPayloadSanitizesSimpleNamespace: """Regression: ``_hook_jsonable`` referenced ``SimpleNamespace`` without diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 0c24adc4ed6..51115fc1d5e 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -821,6 +821,81 @@ def test_run_conversation_codex_plain_text(monkeypatch): assert result["messages"][-1]["content"] == "OK" +def test_copilot_final_preflight_sanitizes_both_middleware_layers(monkeypatch): + """The dispatch chokepoint must sanitize after every mutable layer.""" + agent = _build_copilot_agent(monkeypatch) + setattr(agent, "_disable_streaming", True) + captured = {} + + def _message_item(item_id, *, text, phase, status): + return { + "type": "message", + "role": "assistant", + "status": status, + "content": [{"type": "output_text", "text": text}], + "id": item_id, + "phase": phase, + } + + def _request_middleware(request, **_context): + replacement = dict(request) + replacement["input"] = [ + _message_item( + "request_middleware_id", + text="request-layer", + phase="commentary", + status="completed", + ) + ] + return SimpleNamespace( + payload=replacement, + original_payload=request, + changed=True, + trace=[], + ) + + def _execution_middleware(request, next_call, **_context): + # Request middleware runs after the initial preflight, so its ID is + # still present here. The dispatch chokepoint must remove the ID that + # this execution middleware introduces immediately before the API call. + assert request["input"][0]["id"] == "request_middleware_id" + replacement = dict(request) + replacement["input"] = [ + _message_item( + "execution_middleware_id", + text="execution-layer", + phase="final_answer", + status="in_progress", + ) + ] + return next_call(replacement) + + def _capture_api_call(api_kwargs): + captured.update(api_kwargs) + return _codex_message_response("OK") + + monkeypatch.setattr( + "hermes_cli.middleware.apply_llm_request_middleware", + _request_middleware, + ) + monkeypatch.setattr( + "hermes_cli.middleware.run_llm_execution_middleware", + _execution_middleware, + ) + monkeypatch.setattr(agent, "_interruptible_api_call", _capture_api_call) + + result = agent.run_conversation("Say OK") + + assert result["completed"] is True + message_item = captured["input"][0] + assert "id" not in message_item + assert message_item["status"] == "in_progress" + assert message_item["phase"] == "final_answer" + assert message_item["content"] == [ + {"type": "output_text", "text": "execution-layer"} + ] + + def test_run_conversation_codex_empty_output_with_output_text(monkeypatch): """Regression: empty response.output + valid output_text should succeed, not trigger retry/fallback. The validation stage must defer to diff --git a/tests/run_agent/test_switch_model_pool_reload_52727.py b/tests/run_agent/test_switch_model_pool_reload_52727.py index a1dba807f9f..e1ef7d651c0 100644 --- a/tests/run_agent/test_switch_model_pool_reload_52727.py +++ b/tests/run_agent/test_switch_model_pool_reload_52727.py @@ -213,6 +213,7 @@ class TestSwitchModelReloadsCredentialPool: ) # The switch itself completed (provider/model updated) even though - # the pool reload failed. + # the pool reload failed, without retaining the old provider's pool. assert agent.provider == "groq" - assert agent.model == "llama-3.3-70b" \ No newline at end of file + assert agent.model == "llama-3.3-70b" + assert agent._credential_pool is None diff --git a/tests/run_agent/test_switch_model_reapplies_headers.py b/tests/run_agent/test_switch_model_reapplies_headers.py new file mode 100644 index 00000000000..cd24bb622ba --- /dev/null +++ b/tests/run_agent/test_switch_model_reapplies_headers.py @@ -0,0 +1,100 @@ +"""Regression tests for #61099: switch_model must reapply provider-specific +default headers when it rebuilds _client_kwargs from scratch. + +Without _apply_client_headers_for_base_url() in the rebuild path, a /model +switch drops OpenRouter attribution headers (HTTP-Referer / X-Title → logs +show "Unknown") and, worse, functional headers like Kimi's User-Agent +sentinel (403 without it). +""" + +from unittest.mock import MagicMock, patch + +from run_agent import AIAgent +from agent.context_compressor import ContextCompressor + + +def _make_agent(provider="copilot", base_url="https://api.githubcopilot.com") -> AIAgent: + """Minimal AIAgent with a context_compressor, skipping __init__.""" + agent = AIAgent.__new__(AIAgent) + + agent.model = "claude-opus-4.8" + agent.provider = provider + agent.base_url = base_url + agent.api_key = "sk-primary" + agent.api_mode = "chat_completions" + agent.client = MagicMock() + agent.quiet_mode = True + agent._config_context_length = None + agent._client_kwargs = {"api_key": "sk-primary", "base_url": base_url} + + compressor = ContextCompressor( + model=agent.model, + threshold_percent=0.50, + base_url=base_url, + api_key="sk-primary", + provider=provider, + quiet_mode=True, + config_context_length=None, + ) + agent.context_compressor = compressor + agent._primary_runtime = {} + + return agent + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_to_openrouter_reapplies_attribution_headers(mock_ctx_len): + """Switching to an openrouter.ai base_url must attach the OpenRouter + attribution headers (HTTP-Referer / X-Title) to the rebuilt client + kwargs — not ship a bare api_key+base_url client (#61099).""" + agent = _make_agent(provider="copilot", base_url="https://api.githubcopilot.com") + + agent.switch_model( + "deepseek/deepseek-chat", + "openrouter", + api_key="sk-or-new", + base_url="https://openrouter.ai/api/v1", + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert "HTTP-Referer" in headers + assert headers.get("X-Title") + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_to_kimi_reapplies_user_agent_sentinel(mock_ctx_len): + """Kimi requires a User-Agent sentinel; a switch to api.kimi.com must + carry it or every request 403s.""" + agent = _make_agent(provider="openrouter", base_url="https://openrouter.ai/api/v1") + + agent.switch_model( + "kimi-k2", + "kimi", + api_key="sk-kimi", + base_url="https://api.kimi.com/v1", + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert headers.get("User-Agent", "").startswith("claude-code/") + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_away_from_headered_provider_clears_stale_headers(mock_ctx_len): + """Switching FROM a headered provider TO one with no URL-specific headers + must not carry the old provider's headers along.""" + agent = _make_agent(provider="openrouter", base_url="https://openrouter.ai/api/v1") + agent._client_kwargs["default_headers"] = { + "HTTP-Referer": "https://hermes-agent.nousresearch.com", + "X-Title": "Hermes Agent", + } + + agent.switch_model( + "MiniMax-M3", + "custom:minimax", + api_key="sk-minimax", + base_url="https://api.minimax.io/v1", + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert "HTTP-Referer" not in headers + assert "X-Title" not in headers diff --git a/tests/run_agent/test_switch_model_stale_base_url.py b/tests/run_agent/test_switch_model_stale_base_url.py new file mode 100644 index 00000000000..bd38eb6c498 --- /dev/null +++ b/tests/run_agent/test_switch_model_stale_base_url.py @@ -0,0 +1,87 @@ +"""Regression tests for #47828: switch_model must not pair a new provider +label with the previous provider's base_url when the resolver returns no +new base_url for a genuine provider change. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent +from agent.context_compressor import ContextCompressor + + +def _make_agent_with_compressor(provider="copilot", base_url="https://api.githubcopilot.com") -> AIAgent: + """Build a minimal AIAgent with a context_compressor, skipping __init__.""" + agent = AIAgent.__new__(AIAgent) + + agent.model = "claude-opus-4.8" + agent.provider = provider + agent.base_url = base_url + agent.api_key = "sk-primary" + agent.api_mode = "chat_completions" + agent.client = MagicMock() + agent.quiet_mode = True + agent._config_context_length = None + + compressor = ContextCompressor( + model=agent.model, + threshold_percent=0.50, + base_url=base_url, + api_key="sk-primary", + provider=provider, + quiet_mode=True, + config_context_length=None, + ) + agent.context_compressor = compressor + agent._primary_runtime = {} + + return agent + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_model_rejects_stale_base_url_on_provider_change(mock_ctx_len): + """A provider change with no resolved base_url must fail loud instead of + silently keeping the previous provider's endpoint (#47828).""" + agent = _make_agent_with_compressor(provider="copilot", base_url="https://api.githubcopilot.com") + + with pytest.raises(ValueError, match="no base_url resolved"): + agent.switch_model("MiniMax-M3", "custom:minimax", api_key="sk-minimax", base_url="") + + # Rollback must leave the agent fully on the old (provider, base_url) pair — + # not a mismatched new-model/old-endpoint hybrid. + assert agent.provider == "copilot" + assert agent.base_url == "https://api.githubcopilot.com" + assert agent.model == "claude-opus-4.8" + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_model_allows_empty_base_url_for_same_provider(mock_ctx_len): + """Re-selecting the SAME provider (e.g. a credential-only refresh) with no + new base_url must keep the current URL — this is not a provider change.""" + agent = _make_agent_with_compressor(provider="openrouter", base_url="https://openrouter.ai/api/v1") + + agent.switch_model("new-model", "openrouter", api_key="sk-new", base_url="") + + assert agent.provider == "openrouter" + assert agent.base_url == "https://openrouter.ai/api/v1" + assert agent.model == "new-model" + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_model_applies_new_base_url_on_provider_change(mock_ctx_len): + """The normal, resolved-correctly path must still work: new provider + + new base_url is applied as-is.""" + agent = _make_agent_with_compressor(provider="copilot", base_url="https://api.githubcopilot.com") + + agent.switch_model( + "MiniMax-M3", "custom:minimax", api_key="sk-minimax", base_url="https://api.minimax.io/v1" + ) + + assert agent.provider == "custom:minimax" + assert agent.base_url == "https://api.minimax.io/v1" + assert agent.model == "MiniMax-M3" + # _primary_runtime must snapshot the coherent pair so it survives every + # subsequent restore_primary_runtime() call across turns. + assert agent._primary_runtime["provider"] == "custom:minimax" + assert agent._primary_runtime["base_url"] == "https://api.minimax.io/v1" diff --git a/tests/run_agent/test_verification_continuation_budget.py b/tests/run_agent/test_verification_continuation_budget.py new file mode 100644 index 00000000000..d6f65407e7a --- /dev/null +++ b/tests/run_agent/test_verification_continuation_budget.py @@ -0,0 +1,146 @@ +"""End-to-end regression coverage for verification budget exhaustion (#61631).""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent + + +def _response(content="composed report"): + message = SimpleNamespace(content=content, tool_calls=None) + return SimpleNamespace( + choices=[SimpleNamespace(message=message, finish_reason="stop")], + model="test/model", + usage=None, + ) + + +@pytest.fixture +def agent(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + instance = AIAgent( + session_id="verify-budget-test", + api_key="test-key", + base_url="https://example.invalid/v1", + provider="openai-compat", + model="test/model", + max_iterations=1, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + instance._cached_system_prompt = "stable test prompt" + instance._session_db = None + instance._session_json_enabled = False + instance.save_trajectories = False + instance.compression_enabled = False + instance._cleanup_task_resources = lambda *_a, **_kw: None + instance._save_trajectory = lambda *_a, **_kw: None + return instance + + +def _assert_pending_response_survives(agent, result): + assert result["final_response"] == "composed report" + assert result["turn_exit_reason"] == "max_iterations_reached(1/1)" + assert result["completed"] is False + assert agent._handle_max_iterations.call_count == 0 + assert [message["role"] for message in result["messages"]] == [ + "user", + "assistant", + "user", + "assistant", + ] + + +def test_verify_on_stop_preserves_composed_report_at_budget_limit(agent, monkeypatch): + def model_call(_api_kwargs): + agent._turn_file_mutation_paths = {"changed.py"} + return _response() + + agent._interruptible_api_call = model_call + agent._handle_max_iterations = MagicMock(return_value="replacement summary") + monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "1") + + with ( + patch("agent.verification_stop.build_verify_on_stop_nudge", return_value="verify it"), + patch("hermes_cli.plugins.invoke_hook", return_value=[]), + ): + result = agent.run_conversation("edit changed.py") + + _assert_pending_response_survives(agent, result) + assert result["messages"][1]["_verification_stop_synthetic"] is True + assert result["messages"][2]["_verification_stop_synthetic"] is True + + +def test_pre_verify_preserves_composed_report_at_budget_limit(agent, monkeypatch): + def model_call(_api_kwargs): + agent._turn_file_mutation_paths = {"changed.py"} + return _response() + + agent._interruptible_api_call = model_call + agent._handle_max_iterations = MagicMock(return_value="replacement summary") + monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "0") + + with ( + patch("hermes_cli.plugins.has_hook", side_effect=lambda name: name == "pre_verify"), + patch( + "hermes_cli.plugins.get_pre_verify_continue_message", + return_value="run project tests", + ), + patch("agent.verify_hooks.max_verify_nudges", return_value=2), + patch("hermes_cli.plugins.invoke_hook", return_value=[]), + ): + result = agent.run_conversation("edit changed.py") + + _assert_pending_response_survives(agent, result) + assert result["messages"][1]["_pre_verify_synthetic"] is True + assert result["messages"][2]["_pre_verify_synthetic"] is True + + +def test_intermediate_ack_uses_summary_instead_of_premature_text(agent, monkeypatch): + agent.valid_tool_names = ["web_search"] + agent._intent_ack_continuation = True + agent._looks_like_codex_intermediate_ack = MagicMock(return_value=True) + agent._interruptible_api_call = lambda _kwargs: _response("I'll inspect the files now") + agent._handle_max_iterations = MagicMock(return_value="verified summary.") + monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "0") + + with ( + patch("hermes_cli.plugins.has_hook", return_value=False), + patch("hermes_cli.plugins.invoke_hook", return_value=[]), + ): + result = agent.run_conversation("inspect /tmp/project") + + assert result["final_response"] == "verified summary." + assert result["turn_exit_reason"] == "max_iterations_reached(1/1)" + agent._handle_max_iterations.assert_called_once() + + +def test_later_verified_response_supersedes_pending_report(agent, monkeypatch): + agent.max_iterations = 2 + agent.iteration_budget.max_total = 2 + answers = iter([_response("premature report"), _response("verified final report")]) + agent._interruptible_api_call = lambda _kwargs: next(answers) + agent._handle_max_iterations = MagicMock(return_value="replacement summary") + monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "1") + + with ( + patch( + "agent.verification_stop.build_verify_on_stop_nudge", + side_effect=["verify it", None], + ), + patch("hermes_cli.plugins.invoke_hook", return_value=[]), + ): + result = agent.run_conversation("edit changed.py") + + assert result["final_response"] == "verified final report" + assert result["turn_exit_reason"] == "text_response(finish_reason=stop)" + assert result["completed"] is True + agent._handle_max_iterations.assert_not_called() diff --git a/tests/test_desktop_electron_pin.py b/tests/test_desktop_electron_pin.py index 2943dc9c9fe..ff9f1554e7a 100644 --- a/tests/test_desktop_electron_pin.py +++ b/tests/test_desktop_electron_pin.py @@ -93,43 +93,4 @@ def test_lockfile_resolves_the_pinned_electron(): f"package-lock.json resolves electron to {sorted(set(resolved))}, " f"but the pin is {spec!r}; run `npm install --package-lock-only` so " "`npm ci` stays consistent." - ) - - -DESKTOP_DIR = REPO_ROOT / "apps" / "desktop" -ELECTRON_BUILDER_WRAPPER = DESKTOP_DIR / "scripts" / "run-electron-builder.cjs" - - -def test_no_static_electron_dist_that_can_drift(): - """build.electronDist must not be a static path — hoisting is non-deterministic.""" - assert "electronDist" not in _desktop_pkg().get("build", {}), ( - "build.electronDist is hardcoded again. npm hoisting is non-deterministic, " - "so a static path silently breaks packaging when the layout changes. Let " - "scripts/run-electron-builder.cjs resolve it dynamically instead." - ) - - -def test_builder_script_routes_through_dynamic_resolver(): - """npm run builder must invoke run-electron-builder.cjs, not bare electron-builder.""" - builder = _desktop_pkg().get("scripts", {}).get("builder", "") - assert "run-electron-builder.cjs" in builder, ( - f"the 'builder' script must run scripts/run-electron-builder.cjs, got " - f"{builder!r}" - ) - assert ELECTRON_BUILDER_WRAPPER.is_file(), ( - f"missing dynamic-resolver wrapper at {ELECTRON_BUILDER_WRAPPER}" - ) - - -def test_resolver_uses_node_module_resolution(): - """Wrapper must resolve electron via require.resolve and pass -c.electronDist.""" - src = ELECTRON_BUILDER_WRAPPER.read_text(encoding="utf-8") - assert 'require.resolve("electron/package.json")' in src, ( - "run-electron-builder.cjs must resolve electron via " - "require.resolve('electron/package.json') to stay hoist-proof." - ) - # And it must hand the resolved dist to electron-builder as an override. - assert "-c.electronDist=" in src, ( - "run-electron-builder.cjs must pass the resolved dist to electron-builder " - "via -c.electronDist." - ) + ) \ No newline at end of file diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index e4b064ed947..44a449d91d0 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -483,10 +483,10 @@ class TestParseReasoningEffort: """Guard against silently dropping a documented level. The docstring promises "minimal", "low", "medium", "high", "xhigh", - "max". If someone removes one from VALID_REASONING_EFFORTS without + "max", "ultra". If someone removes one from VALID_REASONING_EFFORTS without updating the docstring, this test will fail and force the call out. """ - documented = {"minimal", "low", "medium", "high", "xhigh", "max"} + documented = {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"} assert documented.issubset(set(VALID_REASONING_EFFORTS)) @@ -834,3 +834,38 @@ class TestGetHermesDir: legacy.symlink_to(empty) result = get_hermes_dir("cache/audio", "audio_cache") assert result == tmp_path / "cache/audio" + + +class TestWslPathTranslation: + """Cross-boundary path translation for a Windows-host UI + WSL backend.""" + + def test_windows_drive_to_wsl_mount(self): + assert hermes_constants.windows_path_to_wsl(r"C:\Users\alex") == "/mnt/c/Users/alex" + assert hermes_constants.windows_path_to_wsl("C:/Users/alex") == "/mnt/c/Users/alex" + assert hermes_constants.windows_path_to_wsl("D:\\") == "/mnt/d/" + + def test_windows_drive_ignores_non_drive_paths(self): + assert hermes_constants.windows_path_to_wsl("/home/alex") is None + assert hermes_constants.windows_path_to_wsl("relative\\dir") is None + + def test_wsl_unc_to_posix_both_spellings(self): + assert hermes_constants.wsl_unc_path_to_posix(r"\\wsl.localhost\Ubuntu\home\alex") == "/home/alex" + assert hermes_constants.wsl_unc_path_to_posix(r"\\wsl$\Ubuntu\home\alex") == "/home/alex" + # Forward-slash spelling and distro root. + assert hermes_constants.wsl_unc_path_to_posix("//wsl.localhost/Debian/srv/app") == "/srv/app" + assert hermes_constants.wsl_unc_path_to_posix("\\\\wsl.localhost\\Ubuntu\\") == "/" + + def test_wsl_unc_ignores_non_unc_paths(self): + assert hermes_constants.wsl_unc_path_to_posix(r"C:\Users\alex") is None + assert hermes_constants.wsl_unc_path_to_posix("/home/alex") is None + + def test_translate_is_noop_off_wsl(self, monkeypatch): + monkeypatch.setattr(hermes_constants, "is_wsl", lambda: False) + assert hermes_constants.translate_cwd_for_wsl_backend(r"C:\Users\alex") == r"C:\Users\alex" + + def test_translate_maps_windows_and_unc_on_wsl(self, monkeypatch): + monkeypatch.setattr(hermes_constants, "is_wsl", lambda: True) + assert hermes_constants.translate_cwd_for_wsl_backend(r"C:\Users\alex") == "/mnt/c/Users/alex" + assert hermes_constants.translate_cwd_for_wsl_backend(r"\\wsl.localhost\Ubuntu\home\alex") == "/home/alex" + # Already-POSIX paths pass through untouched. + assert hermes_constants.translate_cwd_for_wsl_backend("/home/alex") == "/home/alex" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 99f2ccb71e9..c45ad6344cc 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -272,6 +272,43 @@ class TestSessionLifecycle: session = db.get_session("s1") assert session["model"] == "openai/gpt-5.4" + def test_first_accounted_fallback_replaces_requested_primary_route(self, db): + """First successful fallback usage must persist one coherent route pair.""" + db.create_session(session_id="s1", source="cli", model="gpt-5.6-sol") + + db.update_token_counts( + "s1", + input_tokens=10, + output_tokens=5, + model="glm-5.2", + billing_provider="custom:zai", + billing_base_url="https://api.z.ai/api/coding/paas/v4/", + api_call_count=1, + ) + + session = db.get_session("s1") + assert session["model"] == "glm-5.2" + assert session["billing_provider"] == "custom:zai" + assert session["billing_base_url"] == "https://api.z.ai/api/coding/paas/v4/" + assert session["api_call_count"] == 1 + + def test_accounted_primary_route_is_not_rewritten_by_later_fallback(self, db): + """A mixed-provider session keeps its first accounted route in the legacy row.""" + db.create_session(session_id="s1", source="cli", model="gpt-5.6-sol") + db.update_token_counts( + "s1", input_tokens=10, output_tokens=5, model="gpt-5.6-sol", + billing_provider="openai-codex", api_call_count=1, + ) + db.update_token_counts( + "s1", input_tokens=10, output_tokens=5, model="glm-5.2", + billing_provider="custom:zai", api_call_count=1, + ) + + session = db.get_session("s1") + assert session["model"] == "gpt-5.6-sol" + assert session["billing_provider"] == "openai-codex" + assert session["api_call_count"] == 2 + def test_update_token_counts_preserves_existing_model(self, db): db.create_session(session_id="s1", source="cli", model="anthropic/claude-opus-4.6") db.update_token_counts("s1", input_tokens=10, output_tokens=5, model="openai/gpt-5.4") @@ -351,6 +388,181 @@ class TestSessionLifecycle: assert sess["billing_provider"] == "openai" assert sess["billing_mode"] == "local" # preserved (COALESCE on None) + def test_per_model_usage_recorded_for_single_model(self, db): + """Each per-call delta lands in session_model_usage (#51607).""" + db.create_session(session_id="s1", source="cli") + db.update_token_counts("s1", input_tokens=200, output_tokens=100, + model="anthropic/claude-opus-4.8", + billing_provider="anthropic", api_call_count=1) + db.update_token_counts("s1", input_tokens=100, output_tokens=50, + model="anthropic/claude-opus-4.8", + billing_provider="anthropic", api_call_count=1) + + rows = db._conn.execute( + "SELECT model, billing_provider, api_call_count, input_tokens, " + "output_tokens FROM session_model_usage WHERE session_id = 's1'" + ).fetchall() + assert len(rows) == 1 + row = rows[0] + assert row["model"] == "anthropic/claude-opus-4.8" + assert row["billing_provider"] == "anthropic" + assert row["api_call_count"] == 2 + assert row["input_tokens"] == 300 + assert row["output_tokens"] == 150 + + def test_mid_session_switch_splits_per_model_usage(self, db): + """The headline #51607 case: tokens after a /model switch are + attributed to the new model, not the session's initial model. + + The ``sessions`` summary row still holds combined totals + the latest + model, but session_model_usage keeps an accurate per-model split. + """ + db.create_session(session_id="s1", source="cli", + model="deepseek/deepseek-v4-pro") + # Pre-switch calls on deepseek. + db.update_token_counts("s1", input_tokens=40_000, output_tokens=8_000, + model="deepseek/deepseek-v4-pro", + billing_provider="deepseek", api_call_count=2) + # User runs /model — the gateway persists the new model … + db.update_session_model("s1", "anthropic/claude-opus-4.8") + # … and subsequent per-call deltas carry the new model/provider. + db.update_token_counts("s1", input_tokens=50_000, output_tokens=4_000, + model="anthropic/claude-opus-4.8", + billing_provider="openrouter", api_call_count=3) + + rows = { + r["model"]: r + for r in db._conn.execute( + "SELECT model, billing_provider, input_tokens, output_tokens, " + "api_call_count FROM session_model_usage WHERE session_id = 's1'" + ).fetchall() + } + assert set(rows) == {"deepseek/deepseek-v4-pro", + "anthropic/claude-opus-4.8"} + assert rows["deepseek/deepseek-v4-pro"]["input_tokens"] == 40_000 + assert rows["deepseek/deepseek-v4-pro"]["api_call_count"] == 2 + assert rows["anthropic/claude-opus-4.8"]["input_tokens"] == 50_000 + assert rows["anthropic/claude-opus-4.8"]["billing_provider"] == "openrouter" + assert rows["anthropic/claude-opus-4.8"]["api_call_count"] == 3 + + # Summary row: latest model + combined totals (unchanged behaviour). + session = db.get_session("s1") + assert session["model"] == "anthropic/claude-opus-4.8" + assert session["input_tokens"] == 90_000 + assert session["output_tokens"] == 12_000 + + def test_per_model_usage_falls_back_to_session_model(self, db): + """When a call omits the model, attribute it to the session's + recorded model — matches the COALESCE-from-session summary behaviour + and keeps existing callers (which pass no model) working. + """ + db.create_session(session_id="s1", source="cli", + model="gpt-4o", ) + db.update_token_counts("s1", input_tokens=10, output_tokens=5) + + rows = db._conn.execute( + "SELECT model FROM session_model_usage WHERE session_id = 's1'" + ).fetchall() + assert len(rows) == 1 + assert rows[0]["model"] == "gpt-4o" + + def test_absolute_update_does_not_record_per_model(self, db): + """absolute=True overwrites the cumulative summary row (gateway path) + and must NOT add per-model rows — those are accumulated from the + per-call incremental path, so recording here would double-count. + """ + db.create_session(session_id="s1", source="cli", model="gpt-4o") + db.update_token_counts("s1", input_tokens=500, output_tokens=200, + model="gpt-4o", absolute=True) + + rows = db._conn.execute( + "SELECT COUNT(*) AS n FROM session_model_usage WHERE session_id = 's1'" + ).fetchone() + assert rows["n"] == 0 + + def test_per_model_usage_keeps_distinct_billing_routes(self, db): + """The same model through distinct billing routes must not collapse.""" + db.create_session(session_id="routes", source="cli", model="shared-model") + db.update_token_counts( + "routes", input_tokens=10, model="shared-model", + billing_provider="custom", billing_base_url="https://one.example/v1", + billing_mode="api_key", estimated_cost_usd=0.01, api_call_count=1, + ) + db.update_token_counts( + "routes", input_tokens=20, model="shared-model", + billing_provider="custom", billing_base_url="https://two.example/v1", + billing_mode="subscription_included", estimated_cost_usd=0.0, + cost_status="included", api_call_count=1, + ) + + rows = db._conn.execute( + "SELECT billing_base_url, billing_mode, input_tokens " + "FROM session_model_usage WHERE session_id = 'routes' " + "ORDER BY billing_base_url" + ).fetchall() + assert [(r["billing_base_url"], r["billing_mode"], r["input_tokens"]) + for r in rows] == [ + ("https://one.example/v1", "api_key", 10), + ("https://two.example/v1", "subscription_included", 20), + ] + + def test_metadata_only_update_does_not_replace_requested_route(self, db): + db.create_session(session_id="metadata", source="cli", model="primary") + db.update_token_counts( + "metadata", model="fallback", billing_provider="fallback-provider", + api_call_count=0, + ) + row = db.get_session("metadata") + assert row["model"] == "primary" + assert row["billing_provider"] is None + + def test_first_accounted_route_replaces_all_route_fields_atomically(self, db): + db.create_session(session_id="route", source="cli", model="primary") + db.update_session_billing_route( + "route", provider="primary-provider", + base_url="https://primary.example/v1", billing_mode="api_key", + ) + db.update_token_counts( + "route", model="fallback", billing_provider="fallback-provider", + billing_base_url=None, billing_mode=None, api_call_count=1, + ) + row = db.get_session("route") + assert row["model"] == "fallback" + assert row["billing_provider"] == "fallback-provider" + assert row["billing_base_url"] is None + assert row["billing_mode"] is None + + def test_v17_backfill_seeds_existing_session_usage(self, tmp_path): + """A DB upgraded from <17 seeds one usage row per historical session + from its aggregate totals, so insights read uniformly from the table. + """ + db_path = tmp_path / "legacy.db" + db = SessionDB(db_path=db_path) + db.create_session(session_id="legacy1", source="cli", model="gpt-4o") + db.update_token_counts("legacy1", input_tokens=1234, output_tokens=567, + model="gpt-4o", billing_provider="openai") + # Simulate a pre-v17 database: drop the per-model rows and roll the + # recorded schema version back so the backfill migration re-runs. + db._conn.execute("DELETE FROM session_model_usage") + db._conn.execute("UPDATE schema_version SET version = 16") + db._conn.commit() + db.close() + + # Reopen — _init_schema should backfill from the sessions aggregate. + db2 = SessionDB(db_path=db_path) + try: + rows = db2._conn.execute( + "SELECT model, billing_provider, input_tokens, output_tokens " + "FROM session_model_usage WHERE session_id = 'legacy1'" + ).fetchall() + assert len(rows) == 1 + assert rows[0]["model"] == "gpt-4o" + assert rows[0]["billing_provider"] == "openai" + assert rows[0]["input_tokens"] == 1234 + assert rows[0]["output_tokens"] == 567 + finally: + db2.close() + def test_parent_session(self, db): db.create_session(session_id="parent", source="cli") db.create_session(session_id="child", source="cli", parent_session_id="parent") @@ -934,6 +1146,19 @@ class TestMessageStorage: tool_msg = next(m for m in msgs if m["role"] == "tool") assert tool_msg["tool_name"] == "web_search" + def test_tool_effect_disposition_round_trips_through_session_db(self, db): + from agent.tool_dispatch_helpers import make_tool_result_message + + db.create_session(session_id="s1", source="cli") + db.replace_messages( + "s1", + [make_tool_result_message( + "write_file", "worker detached", "c1", effect_disposition="unknown" + )], + ) + + assert db.get_messages_as_conversation("s1")[0]["effect_disposition"] == "unknown" + def test_replace_messages_handles_multimodal_content(self, db): """`replace_messages` (used by /retry, /undo, /compress) must also handle list content without crashing.""" @@ -1881,6 +2106,18 @@ class TestDeleteAndExport: assert db.get_session("s1") is None assert db.message_count(session_id="s1") == 0 + def test_delete_session_cascades_per_model_usage(self, db): + db.create_session(session_id="usage", source="cli", model="gpt-5") + db.update_token_counts( + "usage", input_tokens=10, model="gpt-5", + billing_provider="openai", api_call_count=1, + ) + assert db.delete_session("usage") is True + count = db._conn.execute( + "SELECT COUNT(*) FROM session_model_usage WHERE session_id = 'usage'" + ).fetchone()[0] + assert count == 0 + def test_delete_nonexistent(self, db): assert db.delete_session("nope") is False @@ -1931,6 +2168,221 @@ class TestDeleteAndExport: assert len(exports) == 1 assert exports[0]["source"] == "cli" + def test_import_exported_session_round_trips(self, db, tmp_path): + db.create_session( + session_id="s1", + source="cli", + model="test-model", + model_config={"temperature": 0.2}, + user_id="user-1", + cwd="/workspace", + ) + db.set_session_title("s1", "Imported session") + db.update_session_cwd( + "s1", + "/workspace/project", + git_branch="feature/import", + git_repo_root="/workspace/project", + ) + db.append_message("s1", role="user", content="Hello", timestamp=10) + db.append_message( + "s1", + role="assistant", + content="Hi", + timestamp=11, + tool_calls=[{"id": "call-1", "function": {"name": "noop"}}], + reasoning_details=[{"type": "summary", "text": "short"}], + ) + db.end_session("s1", "complete") + + exported = db.export_session("s1") + exported["handoff_state"] = "active" + exported["handoff_platform"] = "telegram" + exported["handoff_error"] = "stale runtime state" + exported["rewind_count"] = 3 + target = SessionDB(db_path=tmp_path / "target_state.db") + try: + result = target.import_sessions([exported]) + assert result["ok"] is True + assert result["imported"] == 1 + assert result["skipped"] == 0 + + imported = target.get_session("s1") + assert imported["title"] == "Imported session" + assert imported["source"] == "cli" + assert imported["model"] == "test-model" + assert imported["cwd"] == "/workspace/project" + assert imported["git_branch"] == "feature/import" + assert imported["git_repo_root"] == "/workspace/project" + assert imported["message_count"] == 2 + assert imported["tool_call_count"] == 1 + assert imported["handoff_state"] is None + assert imported["handoff_platform"] is None + assert imported["handoff_error"] is None + assert imported["rewind_count"] == 0 + + messages = target.get_messages("s1") + assert [m["role"] for m in messages] == ["user", "assistant"] + assert messages[0]["content"] == "Hello" + assert messages[1]["tool_calls"][0]["id"] == "call-1" + + duplicate = target.import_sessions([exported]) + assert duplicate["imported"] == 0 + assert duplicate["skipped"] == 1 + assert duplicate["skipped_ids"] == ["s1"] + finally: + target.close() + + def test_import_sessions_restores_valid_parents_and_detaches_missing(self, db): + result = db.import_sessions( + [ + { + "id": "child", + "source": "cli", + "parent_session_id": "parent", + "messages": [], + }, + {"id": "parent", "source": "cli", "messages": []}, + { + "id": "orphan", + "source": "cli", + "parent_session_id": "missing", + "messages": [], + }, + ] + ) + + assert result["ok"] is True + assert result["imported"] == 3 + assert result["detached"] == 1 + assert db.get_session("child")["parent_session_id"] == "parent" + assert db.get_session("orphan")["parent_session_id"] is None + + def test_import_sessions_rejects_invalid_batch_atomically(self, db): + result = db.import_sessions( + [ + {"id": "valid", "source": "cli", "messages": []}, + {"source": "cli", "messages": []}, + ] + ) + + assert result["ok"] is False + assert result["imported"] == 0 + assert result["errors"] == [ + {"index": 1, "error": "session id is required"} + ] + assert db.get_session("valid") is None + + def test_import_sessions_detaches_cycle_and_lineage_still_terminates(self, db): + result = db.import_sessions( + [ + { + "id": "a", + "source": "cli", + "parent_session_id": "b", + "end_reason": "compression", + "messages": [], + }, + { + "id": "b", + "source": "cli", + "parent_session_id": "a", + "end_reason": "compression", + "messages": [], + }, + ] + ) + + assert result["ok"] is True + assert result["detached"] == 1 + assert db.get_session("a")["parent_session_id"] is None + assert db.get_session("b")["parent_session_id"] == "a" + assert db.get_compression_lineage("a") == ["a", "b"] + + def test_import_sessions_detaches_self_parent(self, db): + result = db.import_sessions( + [ + { + "id": "self", + "source": "cli", + "parent_session_id": "self", + "end_reason": "compression", + "messages": [], + } + ] + ) + + assert result["ok"] is True + assert result["detached"] == 1 + assert db.get_session("self")["parent_session_id"] is None + + def test_compression_lineage_terminates_for_preexisting_cycle(self, db): + db.create_session("a", "cli") + db.end_session("a", "compression") + db.create_session("b", "cli", parent_session_id="a") + db.end_session("b", "compression") + db._conn.execute("UPDATE sessions SET parent_session_id = ? WHERE id = ?", ("b", "a")) + db._conn.commit() + + lineage = db.get_compression_lineage("a") + assert set(lineage) == {"a", "b"} + assert len(lineage) == 2 + assert set(db.export_session_lineage("a")["lineage_session_ids"]) == {"a", "b"} + + @pytest.mark.parametrize( + ("payload", "error"), + [ + ( + {"id": "bad-json", "model_config": "{not-json", "messages": []}, + "model_config must be valid JSON", + ), + ( + {"id": "bad-text", "user_id": {"not": "text"}, "messages": []}, + "user_id must be a string", + ), + ( + {"id": "missing-role", "messages": [{"content": "x"}]}, + "messages[0].role must be a non-empty string", + ), + ( + {"id": "null-role", "messages": [{"role": None, "content": "x"}]}, + "messages[0].role must be a non-empty string", + ), + ], + ) + def test_import_sessions_rejects_invalid_metadata(self, db, payload, error): + result = db.import_sessions([payload]) + + assert result["ok"] is False + assert result["errors"] == [{"index": 0, "session_id": payload["id"], "error": error}] + assert db.get_session(payload["id"]) is None + + def test_import_sessions_rejects_oversized_payloads_atomically(self, db): + oversized = "x" * (SessionDB._IMPORT_MAX_SESSION_BYTES + 1) + result = db.import_sessions( + [{"id": "oversized", "messages": [{"role": "user", "content": oversized}]}] + ) + + assert result["ok"] is False + assert result["errors"][0]["error"] == "session exceeds the import size limit" + assert db.get_session("oversized") is None + + result = db.import_sessions( + [ + { + "id": "too-many-messages", + "messages": [ + {"role": "user", "content": "x"} + ] + * (SessionDB._IMPORT_MAX_MESSAGES_PER_SESSION + 1), + } + ] + ) + + assert result["ok"] is False + assert result["errors"][0]["error"] == "messages exceeds the per-session import limit" + assert db.get_session("too-many-messages") is None + # ========================================================================= # Prune @@ -5339,6 +5791,14 @@ def test_expired_compression_failure_cooldown_is_ignored(db): assert db.get_compression_failure_cooldown("s1") is None +def test_compression_fallback_streak_round_trips(db): + db.create_session("s1", "cli") + + assert db.get_compression_fallback_streak("s1") == 0 + db.set_compression_fallback_streak("s1", 2) + assert db.get_compression_fallback_streak("s1") == 2 + + def test_refresh_compression_lock_requires_holder_and_preserves_reclaimability(db, monkeypatch): db.create_session("s1", "cli") diff --git a/tests/test_session_workspace_binding.py b/tests/test_session_workspace_binding.py new file mode 100644 index 00000000000..36e7f2ec3db --- /dev/null +++ b/tests/test_session_workspace_binding.py @@ -0,0 +1,38 @@ +"""Session <-> workspace grouping key (hermes_state.workspace_key). + +The key is what `hermes sessions list --workspace` groups/filters on. It is a +coarse workspace identity derived from fields already recorded on sessions +(git_repo_root, cwd) — no git shelling, no new columns. Branch is deliberately +NOT part of the key. +""" + +from hermes_state import workspace_key + + +def test_repo_root_is_the_key_when_known(): + row = {"git_repo_root": "/www/app", "cwd": "/www/app/src", "git_branch": "feat"} + assert workspace_key(row) == "/www/app" + + +def test_falls_back_to_cwd_for_non_git_sessions(): + assert workspace_key({"cwd": "/work/notes"}) == "/work/notes" + assert workspace_key({"git_repo_root": "", "cwd": "/work/notes"}) == "/work/notes" + + +def test_none_when_unbound(): + assert workspace_key({}) is None + assert workspace_key({"cwd": "", "git_repo_root": ""}) is None + assert workspace_key({"cwd": " "}) is None + + +def test_branch_does_not_affect_the_key(): + # Two sessions on the same repo, different branches, group together. + a = {"git_repo_root": "/www/app", "git_branch": "main"} + b = {"git_repo_root": "/www/app", "git_branch": "feature-x"} + assert workspace_key(a) == workspace_key(b) == "/www/app" + + +def test_repo_root_wins_over_a_differing_cwd(): + # A worktree/subdir session still groups under its repo root, not its cwd. + row = {"git_repo_root": "/www/app", "cwd": "/www/app/.worktrees/x"} + assert workspace_key(row) == "/www/app" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 711956901ee..b8b0bbd7732 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -424,6 +424,43 @@ def test_tui_verbose_tool_events_omit_details_when_redaction_fails(monkeypatch): assert "result_text" not in events[1][2] +def test_tui_tool_output_risk_event_exposes_metadata_without_raw_output(monkeypatch): + events: list[tuple[str, str, dict]] = [] + monkeypatch.setattr( + server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload)) + ) + monkeypatch.setitem( + server._sessions, + "risk-test", + {"tool_progress_mode": "all"}, + ) + + server._on_tool_progress( + "risk-test", + "tool.output_risk", + "web_extract", + tool_call_id="tool-1", + risk_metadata={ + "risk": "high", + "findings": ["prompt_injection"], + "redacted": False, + }, + ) + + assert events == [( + "tool.output_risk", + "risk-test", + { + "tool_id": "tool-1", + "name": "web_extract", + "risk": "high", + "findings": ["prompt_injection"], + "redacted": False, + }, + )] + assert "result" not in events[0][2] + + def test_dispatch_rejects_non_object_request(): resp = server.dispatch([]) @@ -2740,6 +2777,95 @@ def test_config_set_yolo_global_scope_writes_approvals_mode(tmp_path, monkeypatc assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "manual" +def test_config_get_approval_mode_uses_smart_default_when_key_is_missing( + tmp_path, monkeypatch +): + import yaml + + monkeypatch.setattr(server, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"approvals": {"timeout": 15}}) + ) + + response = server.handle_request( + {"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}} + ) + assert response["result"]["value"] == "smart" + + +def test_config_get_approval_mode_fails_safe_to_manual_for_invalid_explicit_value( + tmp_path, monkeypatch +): + import yaml + + monkeypatch.setattr(server, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"approvals": {"mode": "sometimes"}}) + ) + + response = server.handle_request( + {"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}} + ) + assert response["result"]["value"] == "manual" + + +def test_config_get_approval_mode_normalizes_yaml_off(tmp_path, monkeypatch): + import yaml + + monkeypatch.setattr(server, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"approvals": {"mode": False}}) + ) + + response = server.handle_request( + {"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}} + ) + assert response["result"]["value"] == "off" + + +def test_config_set_approval_mode_persists_three_way_value_and_emits_live_status( + tmp_path, monkeypatch +): + import yaml + + monkeypatch.setattr(server, "_hermes_home", tmp_path) + emitted = [] + monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args)) + server._sessions["sid"] = {"agent": object(), "session_key": "profile-session"} + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "approvals.mode", "value": "manual"}, + } + ) + finally: + server._sessions.clear() + + assert resp["result"] == {"key": "approvals.mode", "value": "manual"} + assert yaml.safe_load((tmp_path / "config.yaml").read_text())["approvals"]["mode"] == "manual" + assert emitted and emitted[0][0:2] == ("session.info", "sid") + assert emitted[0][2]["approval_mode"] == "manual" + + +def test_desktop_contract_includes_approval_mode_rpc(): + assert server.DESKTOP_BACKEND_CONTRACT >= 3 + + +def test_config_set_approval_mode_rejects_unknown_value(): + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "approvals.mode", "value": "sometimes"}, + } + ) + + assert resp["error"]["code"] == 4002 + + def test_config_set_yolo_global_scope_honors_explicit_value(tmp_path, monkeypatch): """An explicit value pins global approvals.mode regardless of prior state.""" import yaml diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index e364dcd3be0..ed67099bbf5 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -2,12 +2,15 @@ import ast import os +import tempfile import threading import time from pathlib import Path from types import SimpleNamespace from unittest.mock import patch as mock_patch +import pytest + import tools.approval as approval_module from hermes_constants import get_hermes_home from tools.approval import ( @@ -55,6 +58,11 @@ class TestApprovalModeParsing: class TestSmartApproval: + def test_smart_is_the_default_approval_mode(self): + from hermes_cli.config import DEFAULT_CONFIG + + assert DEFAULT_CONFIG["approvals"]["mode"] == "smart" + def test_smart_approval_uses_call_llm(self): response = SimpleNamespace( choices=[SimpleNamespace(message=SimpleNamespace(content="APPROVE"))] @@ -68,6 +76,35 @@ class TestSmartApproval: assert mock_call.call_args.kwargs["temperature"] == 0 assert mock_call.call_args.kwargs["max_tokens"] == 16 + def test_smart_approval_does_not_allowlist_the_pattern_for_session(self, monkeypatch): + session_key = "test-smart-per-command" + command = "python -c \"print('hello')\"" + dangerous, pattern_key, _ = detect_dangerous_command(command) + assert dangerous is True + + monkeypatch.setenv("HERMES_SESSION_KEY", session_key) + monkeypatch.setenv("HERMES_EXEC_ASK", "1") + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.setattr( + approval_module, + "_get_approval_config", + lambda: {"mode": "smart"}, + ) + monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False) + monkeypatch.setattr(approval_module, "_smart_approve", lambda *_: "approve") + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _command: {"action": "allow", "findings": [], "summary": ""}, + ) + approval_module.clear_session(session_key) + approval_module._permanent_approved.clear() + + result = approval_module.check_all_command_guards(command, "local") + + assert result["approved"] is True + assert result["smart_approved"] is True + assert is_approved(session_key, pattern_key) is False + class TestDetectDangerousRm: def test_rm_rf_detected(self): @@ -82,6 +119,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): @@ -1058,6 +1142,110 @@ class TestFullCommandAlwaysShown: assert result == "deny" +class TestSmartDeniedPrompt: + def test_callback_receives_smart_denied_capability(self): + captured = {} + + def callback(command, description, **kwargs): + captured.update(kwargs) + return "deny" + + result = prompt_dangerous_approval( + "rm -rf /tmp/example", + "recursive delete", + allow_permanent=False, + smart_denied=True, + approval_callback=callback, + ) + + assert result == "deny" + assert captured == {"allow_permanent": False, "smart_denied": True} + + def test_short_prompt_smart_deny_rejects_session_input(self): + with mock_patch("builtins.input", return_value="session"): + result = prompt_dangerous_approval( + "rm -rf /tmp/example", + "recursive delete", + allow_permanent=False, + smart_denied=True, + ) + + assert result == "deny" + + def test_short_prompt_smart_deny_displays_only_once_and_deny(self, capsys): + prompts = [] + + def input_once(prompt): + prompts.append(prompt) + return "deny" + + with mock_patch("builtins.input", side_effect=input_once): + prompt_dangerous_approval( + "rm -rf /tmp/example", + "recursive delete", + allow_permanent=False, + smart_denied=True, + ) + + rendered = capsys.readouterr().out + assert "[o]nce" in rendered and "[d]eny" in rendered + assert "[s]ession" not in rendered and "[a]lways" not in rendered + assert prompts == [" Choice [o/D]: "] + + @pytest.mark.parametrize( + ("lang", "once_key", "deny_key", "once_label", "deny_label"), + [ + ("tr", "b", "r", "[b]ir kez", "[r]eddet"), + ("fr", "o", "r", "[o]ne fois", "[r]efuser"), + ("ja", "o", "d", "[o]今回のみ", "[d]拒否"), + ], + ) + def test_smart_deny_uses_locale_specific_once_deny_choices( + self, monkeypatch, capsys, lang, once_key, deny_key, once_label, deny_label, + ): + monkeypatch.setenv("HERMES_LANGUAGE", lang) + from agent import i18n + i18n.reset_language_cache() + prompts = [] + + def choose_once(prompt): + prompts.append(prompt) + return once_key + + try: + with mock_patch("builtins.input", side_effect=choose_once): + result = prompt_dangerous_approval( + "rm -rf /tmp/example", "recursive delete", + allow_permanent=False, smart_denied=True, + ) + finally: + i18n.reset_language_cache() + + rendered = capsys.readouterr().out + assert result == "once" + assert once_label in rendered + assert deny_label in rendered + assert i18n.t("approval.choose_short", lang=lang).split("|")[1].strip() not in rendered + assert "/".join((once_key, deny_key.upper())) in prompts[0] + + @pytest.mark.parametrize(("lang", "forbidden"), [("tr", "o"), ("fr", "s"), ("ja", "a")]) + def test_smart_deny_rejects_localized_session_or_always_shortcuts( + self, monkeypatch, lang, forbidden, + ): + monkeypatch.setenv("HERMES_LANGUAGE", lang) + from agent import i18n + i18n.reset_language_cache() + try: + with mock_patch("builtins.input", return_value=forbidden): + result = prompt_dangerous_approval( + "rm -rf /tmp/example", "recursive delete", + allow_permanent=False, smart_denied=True, + ) + finally: + i18n.reset_language_cache() + assert result == "deny" + + class TestForkBombDetection: """The fork bomb regex must match the classic :(){ :|:& };: pattern.""" @@ -2060,7 +2248,7 @@ class TestApprovalTimeoutIsNotConsent: SESSION_KEY = "test-no-consent-session" def setup_method(self): - """Reset module state and force tight gateway_timeout for fast tests.""" + """Reset module state and force a tight approval timeout for fast tests.""" from tools import approval as mod mod._gateway_queues.clear() mod._gateway_notify_cbs.clear() @@ -2097,7 +2285,7 @@ class TestApprovalTimeoutIsNotConsent: from tools import approval as mod monkeypatch.setattr( mod, "_get_approval_config", - lambda: {"mode": "manual", "gateway_timeout": seconds, "timeout": seconds}, + lambda: {"mode": "manual", "timeout": seconds}, ) def test_timeout_returns_approved_false_with_no_consent(self, monkeypatch): @@ -2140,11 +2328,13 @@ class TestApprovalTimeoutIsNotConsent: assert "rephrase" in msg.lower() assert "different command" in msg.lower() - def test_explicit_deny_carries_same_no_consent_shape(self): + def test_explicit_deny_carries_same_no_consent_shape(self, monkeypatch): """An explicit /deny must produce the same shape as timeout — the agent should treat both identically.""" from tools import approval as mod + self._force_short_timeout(monkeypatch, seconds=60) + notified = [] mod.register_gateway_notify(self.SESSION_KEY, lambda data: notified.append(data)) diff --git a/tests/tools/test_approval_interrupt.py b/tests/tools/test_approval_interrupt.py index b991afd8088..2ef91f752c3 100644 --- a/tests/tools/test_approval_interrupt.py +++ b/tests/tools/test_approval_interrupt.py @@ -73,7 +73,7 @@ class TestApprovalInterrupt: # Force a long timeout so a *passing* test can only happen via the # interrupt path, never by the deadline elapsing. - mod._get_approval_config = lambda: {"gateway_timeout": 300} + mod._get_approval_config = lambda: {"timeout": 300} approval_data = { "command": "rm -rf /tmp/whatever", @@ -128,7 +128,7 @@ class TestApprovalInterrupt: # Short timeout so the test finishes fast via the deadline, proving the # foreign interrupt did not short-circuit the wait. - mod._get_approval_config = lambda: {"gateway_timeout": 1} + mod._get_approval_config = lambda: {"timeout": 1} approval_data = { "command": "rm -rf /tmp/whatever", diff --git a/tests/tools/test_approval_plugin_hooks.py b/tests/tools/test_approval_plugin_hooks.py index 58ccb2f8a76..5493d274da0 100644 --- a/tests/tools/test_approval_plugin_hooks.py +++ b/tests/tools/test_approval_plugin_hooks.py @@ -13,6 +13,7 @@ import pytest import tools.approval as approval_module from tools.approval import ( check_all_command_guards, + check_execute_code_guard, set_current_session_key, clear_session, ) @@ -150,3 +151,195 @@ class TestGatewayPathFiresHooks: thread.""" +class TestSmartModeFiresHooks: + def _configure(self, monkeypatch, verdict): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + monkeypatch.setenv("HERMES_EXEC_ASK", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False) + monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(approval_module, "_smart_approve", lambda *_: verdict) + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _: {"action": "allow", "findings": [], "summary": ""}, + ) + + @pytest.mark.parametrize( + ("guard", "value", "verdict", "approved", "choice", "pattern_key"), + [ + (check_all_command_guards, "rm -rf /tmp/smart-hook", "approve", True, "smart_approve", None), + (check_all_command_guards, "rm -rf /tmp/smart-hook", "deny", False, "smart_deny", None), + (check_execute_code_guard, "print('smart hook')", "approve", True, "smart_approve", "execute_code"), + (check_execute_code_guard, "print('smart hook')", "deny", False, "smart_deny", "execute_code"), + ], + ) + def test_smart_verdict_fires_redacted_pre_and_post_hooks( + self, isolated_session, monkeypatch, guard, value, verdict, approved, choice, pattern_key + ): + self._configure(monkeypatch, verdict) + secret = "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZ012345" + value = f'{value} # Authorization: Bearer {secret}' + captured = [] + + with patch( + "hermes_cli.plugins.invoke_hook", + side_effect=lambda name, **kwargs: captured.append((name, kwargs)), + ): + result = guard(value, "local") + + assert result["approved"] is approved + assert result[f"smart_{'approved' if approved else 'denied'}"] is True + assert [name for name, _ in captured] == [ + "pre_approval_request", + "post_approval_response", + ] + pre, post = (kwargs for _, kwargs in captured) + assert pre["surface"] == post["surface"] == "smart" + assert post["choice"] == choice + assert post["decided_by"] == "aux_llm" + assert pre["session_key"] == post["session_key"] == isolated_session + assert secret not in pre["command"] + assert secret not in post["command"] + assert pre["pattern_keys"] + assert pre["pattern_key"] == post["pattern_key"] + if pattern_key is not None: + assert pre["pattern_key"] == pattern_key + assert pre["pattern_keys"] == [pattern_key] + + @pytest.mark.parametrize("guard,value", [ + (check_all_command_guards, "rm -rf /tmp/smart-order"), + (check_execute_code_guard, "print('smart order')"), + ]) + def test_pre_hook_fires_before_aux_llm_decision( + self, isolated_session, monkeypatch, guard, value + ): + self._configure(monkeypatch, "approve") + events = [] + + def decide(*_): + events.append("smart_approve") + return "approve" + + monkeypatch.setattr(approval_module, "_smart_approve", decide) + with patch( + "hermes_cli.plugins.invoke_hook", + side_effect=lambda name, **kwargs: events.append(name), + ): + result = guard(value, "local") + + assert result["approved"] is True + assert events == [ + "pre_approval_request", + "smart_approve", + "post_approval_response", + ] + + @pytest.mark.parametrize("guard,value", [ + (check_all_command_guards, "rm -rf /tmp/smart-force-redaction"), + (check_execute_code_guard, "print('smart force redaction')"), + ]) + def test_smart_observer_redaction_is_forced_when_config_disables_redaction( + self, isolated_session, monkeypatch, guard, value + ): + self._configure(monkeypatch, "approve") + force_values = [] + + def redact(text, *, force=False): + force_values.append(force) + return f"redacted:{text}" + + with ( + patch("agent.redact.redact_sensitive_text", side_effect=redact), + patch("hermes_cli.plugins.invoke_hook"), + ): + result = guard(value, "local") + + assert result["approved"] is True + assert force_values == [True, True] + + @pytest.mark.parametrize("guard,value", [ + (check_all_command_guards, "rm -rf /tmp/smart-hook-crash"), + (check_execute_code_guard, "print('smart hook crash')"), + ]) + @pytest.mark.parametrize("verdict,approved", [("approve", True), ("deny", False)]) + def test_observer_exception_never_changes_smart_verdict( + self, isolated_session, monkeypatch, guard, value, verdict, approved + ): + self._configure(monkeypatch, verdict) + with patch( + "hermes_cli.plugins.invoke_hook", + side_effect=RuntimeError("observer failed"), + ): + result = guard(value, "local") + assert result["approved"] is approved + + @pytest.mark.parametrize("guard,value", [ + (check_all_command_guards, "rm -rf /tmp/smart-redactor-crash"), + (check_execute_code_guard, "print('smart redactor crash')"), + ]) + @pytest.mark.parametrize("verdict,approved", [("approve", True), ("deny", False)]) + def test_redactor_exception_never_changes_smart_verdict_or_leaks_payload( + self, isolated_session, monkeypatch, guard, value, verdict, approved + ): + self._configure(monkeypatch, verdict) + captured = [] + + def fail_observer_redaction(text, *, force=False): + if force: + raise RuntimeError("observer redactor failed") + return text + + with ( + patch("agent.redact.redact_sensitive_text", side_effect=fail_observer_redaction), + patch( + "hermes_cli.plugins.invoke_hook", + side_effect=lambda name, **kwargs: captured.append((name, kwargs)), + ), + ): + result = guard(value, "local") + assert result["approved"] is approved + assert captured == [] + + @pytest.mark.parametrize("guard,first_value,second_value", [ + ( + check_all_command_guards, + "rm -rf /tmp/first-smart-command", + "rm -rf /tmp/second-smart-command", + ), + ( + check_execute_code_guard, + "print('first smart script')", + "print('second smart script')", + ), + ]) + def test_smart_approval_is_per_command( + self, isolated_session, monkeypatch, guard, first_value, second_value + ): + verdicts = iter(("approve", "deny")) + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + monkeypatch.setenv("HERMES_EXEC_ASK", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False) + monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(approval_module, "_smart_approve", lambda *_: next(verdicts)) + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _: {"action": "allow", "findings": [], "summary": ""}, + ) + captured = [] + with patch( + "hermes_cli.plugins.invoke_hook", + side_effect=lambda name, **kwargs: captured.append((name, kwargs)), + ): + first = guard(first_value, "local") + second = guard(second_value, "local") + + assert first["approved"] is True + assert second["approved"] is False + assert [kwargs["choice"] for name, kwargs in captured if name == "post_approval_response"] == [ + "smart_approve", + "smart_deny", + ] + + diff --git a/tests/tools/test_async_delegation.py b/tests/tools/test_async_delegation.py index 7714d3c8c08..77732960bca 100644 --- a/tests/tools/test_async_delegation.py +++ b/tests/tools/test_async_delegation.py @@ -5,7 +5,11 @@ onto the shared process_registry.completion_queue, the rich re-injection block formatting, capacity rejection, and crash handling. """ +import json +import os import queue +import subprocess +import sys import threading import time @@ -223,6 +227,181 @@ def test_completed_records_pruned_to_cap(): assert len(ad.list_async_delegations()) <= ad._MAX_RETAINED_COMPLETED +def test_completion_is_persisted_and_delivery_can_be_acknowledged(tmp_path, monkeypatch): + """A finished child remains pending on disk until its queue consumer acks it.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + dispatched = ad.dispatch_async_delegation( + goal="durable", context="ctx", toolsets=["terminal"], role="leaf", + model="m", session_key="owner", parent_session_id="parent", + runner=lambda: {"status": "completed", "summary": "survived"}, + ) + assert _drain_one() is not None + + restored = queue.Queue() + assert ad.restore_undelivered_completions(restored) == 1 + row = ad.get_durable_delegation(dispatched["delegation_id"]) + assert row["origin_session"] == "owner" + assert row["state"] == "completed" + assert row["result"]["summary"] == "survived" + assert row["delivery_state"] == "pending" + # Queue publication/restoration is not a destination delivery attempt. + assert row["delivery_attempts"] == 0 + + assert ad.mark_completion_delivered(dispatched["delegation_id"]) + assert ad.restore_undelivered_completions(queue.Queue()) == 0 + assert ad.get_durable_delegation(dispatched["delegation_id"])["delivery_state"] == "delivered" + + +def test_real_process_restart_restores_owned_completion_once(tmp_path): + """Real-import E2E: a fresh interpreter restores a prior process's result.""" + repo = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + env = {**os.environ, "HERMES_HOME": str(tmp_path), "PYTHONPATH": repo} + producer = r''' +import time +from tools import async_delegation as ad +r = ad.dispatch_async_delegation( + goal="restart", context=None, toolsets=None, role="leaf", model="m", + session_key="owner-session", parent_session_id="durable-parent", + runner=lambda: {"status": "completed", "summary": "after restart"}, +) +deadline = time.time() + 5 +while ad.active_count() and time.time() < deadline: + time.sleep(.01) +print(r["delegation_id"]) +''' + first = subprocess.run( + [sys.executable, "-c", producer], cwd=repo, env=env, + text=True, capture_output=True, timeout=15, check=True, + ) + delegation_id = first.stdout.strip().splitlines()[-1] + + consumer = r''' +import json +from tools.process_registry import process_registry +evt = process_registry.completion_queue.get_nowait() +print(json.dumps(evt, sort_keys=True)) +''' + second = subprocess.run( + [sys.executable, "-c", consumer], cwd=repo, env=env, + text=True, capture_output=True, timeout=15, check=True, + ) + evt = json.loads(second.stdout.strip().splitlines()[-1]) + assert evt["delegation_id"] == delegation_id + assert evt["session_key"] == "owner-session" + assert evt["parent_session_id"] == "durable-parent" + assert evt["summary"] == "after restart" + + acker = f''' +from tools import async_delegation as ad +assert ad.mark_completion_delivered({delegation_id!r}) +''' + subprocess.run( + [sys.executable, "-c", acker], cwd=repo, env=env, + text=True, capture_output=True, timeout=15, check=True, + ) + probe = subprocess.run( + [sys.executable, "-c", "from tools.process_registry import process_registry; print(process_registry.completion_queue.qsize())"], + cwd=repo, env=env, text=True, capture_output=True, timeout=15, check=True, + ) + assert probe.stdout.strip().splitlines()[-1] == "0" + + +def test_submit_failure_removes_durable_running_record(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + class _BrokenExecutor: + def submit(self, *_args, **_kwargs): + raise RuntimeError("submit failed") + + monkeypatch.setattr(ad, "_get_executor", lambda _max_workers: _BrokenExecutor()) + result = ad.dispatch_async_delegation( + goal="never ran", context=None, toolsets=None, role="leaf", model="m", + session_key="owner", runner=lambda: {}, + ) + + assert result["status"] == "rejected" + with ad._DB_LOCK, ad._connect() as conn: + assert conn.execute("SELECT COUNT(*) FROM async_delegations").fetchone()[0] == 0 + + +def test_pending_retention_prunes_delivered_before_undelivered(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(ad, "_MAX_RETAINED_COMPLETED", 2) + for index, delivery_state in enumerate(("pending", "delivered", "pending")): + delegation_id = f"deleg_{index}" + record = { + "delegation_id": delegation_id, + "session_key": "owner", + "origin_ui_session_id": "", + "parent_session_id": None, + "dispatched_at": float(index + 1), + } + ad._persist_dispatch(record) + ad._persist_completion( + { + "delegation_id": delegation_id, + "status": "completed", + "completed_at": float(index + 1), + }, + {"status": "completed", "summary": delegation_id}, + ) + if delivery_state == "delivered": + ad.mark_completion_delivered(delegation_id) + + ad._prune_durable_records() + + assert ad.get_durable_delegation("deleg_0") is not None + assert ad.get_durable_delegation("deleg_1") is None + assert ad.get_durable_delegation("deleg_2") is not None + + +def test_recover_marks_abandoned_running_record_unknown(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + record = { + "delegation_id": "deleg_abandoned", + "session_key": "owner", + "origin_ui_session_id": "", + "parent_session_id": None, + "dispatched_at": 1.0, + } + ad._persist_dispatch(record) + with ad._DB_LOCK, ad._connect() as conn: + conn.execute( + "UPDATE async_delegations SET owner_pid=?, owner_started_at=NULL WHERE delegation_id=?", + (99999999, "deleg_abandoned"), + ) + + assert ad.recover_abandoned_delegations() == 1 + durable = ad.get_durable_delegation("deleg_abandoned") + assert durable["state"] == "unknown" + assert durable["delivery_state"] == "pending" + restored = queue.Queue() + assert ad.restore_undelivered_completions(restored) == 1 + assert restored.get_nowait()["status"] == "unknown" + + +def test_durable_delivery_claim_is_exclusive_and_retryable(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + record = { + "delegation_id": "deleg_claim", "session_key": "owner", + "origin_ui_session_id": "", "parent_session_id": None, + "dispatched_at": 1.0, + } + ad._persist_dispatch(record) + ad._persist_completion( + {"delegation_id": "deleg_claim", "status": "completed", "completed_at": 2.0}, + {"status": "completed", "summary": "done"}, + ) + + assert ad.claim_completion_delivery("deleg_claim", "consumer-a") + assert not ad.claim_completion_delivery("deleg_claim", "consumer-b") + assert ad.release_completion_delivery("deleg_claim", "consumer-a") + assert ad.claim_completion_delivery("deleg_claim", "consumer-b") + assert ad.complete_completion_delivery("deleg_claim", "consumer-b") + assert not ad.claim_completion_delivery("deleg_claim", "consumer-c") + assert ad.get_durable_delegation("deleg_claim")["delivery_state"] == "delivered" + + # --------------------------------------------------------------------------- # Integration: delegate_task(background=True) routing # --------------------------------------------------------------------------- diff --git a/tests/tools/test_base_environment.py b/tests/tools/test_base_environment.py index d629d95ea29..9b962b206b8 100644 --- a/tests/tools/test_base_environment.py +++ b/tests/tools/test_base_environment.py @@ -161,8 +161,8 @@ class TestAtomicSnapshotWrite: captured = {} def fake_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None): - captured["cmd"] = cmd_string - raise RuntimeError("stop after capture") # we only need the script + captured.setdefault("cmd", cmd_string) # only the bootstrap; ignore the failure-path probe + raise RuntimeError("stop after capture") env._run_bash = fake_run_bash # type: ignore[assignment] try: @@ -188,7 +188,7 @@ class TestAtomicSnapshotWrite: captured = {} def fake_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None): - captured["cmd"] = cmd_string + captured.setdefault("cmd", cmd_string) # only the bootstrap; ignore the failure-path probe raise RuntimeError("stop after capture") env._run_bash = fake_run_bash # type: ignore[assignment] @@ -437,6 +437,41 @@ class TestInitSessionFailure: assert len(calls) == 1 assert calls[0]["login"] is True + def test_prefer_nonlogin_when_login_bash_is_dead(self): + """Login snapshot failure + working non-login probe → don't use bash -l.""" + env = _TestableEnv() + + def mock_run_bash(cmd, *, login=False, timeout=120, stdin_data=None): + mock = MagicMock() + mock.poll.return_value = 0 + mock.stdout = iter([]) + if login: + mock.returncode = 1 + else: + mock.returncode = 0 + return mock + + env._run_bash = mock_run_bash + env.init_session() + + assert env._snapshot_ready is False + assert env._prefer_nonlogin is True + + calls = [] + + def track_run_bash(cmd, *, login=False, timeout=120, stdin_data=None): + calls.append({"login": login}) + mock = MagicMock() + mock.poll.return_value = 0 + mock.returncode = 0 + mock.stdout = iter([]) + return mock + + env._run_bash = track_run_bash + env.execute("echo test") + + assert calls[0]["login"] is False + class TestCwdMarker: def test_marker_contains_session_id(self): diff --git a/tests/tools/test_config_null_guard.py b/tests/tools/test_config_null_guard.py index cb80ab8ecf5..30b63c783da 100644 --- a/tests/tools/test_config_null_guard.py +++ b/tests/tools/test_config_null_guard.py @@ -21,7 +21,7 @@ class TestTTSProviderNullGuard: assert result == DEFAULT_PROVIDER.lower().strip() def test_missing_provider_returns_default(self): - """No ``provider`` key at all should also return default.""" + """No ``provider`` key + non-TTS active provider should return default.""" from tools.tts_tool import _get_provider, DEFAULT_PROVIDER result = _get_provider({}) @@ -33,6 +33,27 @@ class TestTTSProviderNullGuard: result = _get_provider({"provider": "OPENAI"}) assert result == "openai" + def test_missing_provider_keeps_free_default_with_cloud_credentials(self): + """A chat-provider key must not silently opt the user into paid TTS.""" + from tools.tts_tool import _get_provider, DEFAULT_PROVIDER + + assert _get_provider({}) == DEFAULT_PROVIDER + assert _get_provider({"provider": None}) == DEFAULT_PROVIDER + + def test_active_provider_without_credentials_keeps_edge(self): + """A TTS-capable active provider that can't authenticate must NOT + silently displace the free Edge default (no surprise billing / hard + errors for a credential-less deployment).""" + from tools.tts_tool import _get_provider, DEFAULT_PROVIDER + + assert _get_provider({}) == DEFAULT_PROVIDER.lower().strip() + + def test_explicit_provider_wins_over_active(self): + """An explicit tts.provider always overrides the active-provider fallback.""" + from tools.tts_tool import _get_provider + + assert _get_provider({"provider": "edge"}) == "edge" + # ── Web tools ───────────────────────────────────────────────────────────── diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 43c8089f112..0fe6e0028eb 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -1265,6 +1265,24 @@ class TestDelegationCredentialResolution(unittest.TestCase): requested="crof.ai", target_model="deepseek-v4-pro-CEER" ) + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + def test_provider_forwards_runtime_request_overrides_and_output_cap(self, mock_resolve): + mock_resolve.return_value = { + "provider": "custom", + "model": "real-model", + "base_url": "https://gateway.example/v1", + "api_key": "gateway-key", + "api_mode": "chat_completions", + "request_overrides": {"extra_body": {"store": False}}, + "max_output_tokens": 3072, + } + creds = _resolve_delegation_credentials( + {"model": "real-model", "provider": "gateway"}, + _make_mock_parent(depth=0), + ) + self.assertEqual(creds["request_overrides"], {"extra_body": {"store": False}}) + self.assertEqual(creds["max_output_tokens"], 3072) + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") def test_standard_provider_not_overwritten_by_configured_name(self, mock_resolve): """Standard (non-custom) providers must still return runtime identity, @@ -1446,6 +1464,8 @@ class TestDelegationProviderIntegration(unittest.TestCase): parent.providers_ignored = ["openai/gpt-4o-mini"] parent.providers_order = ["google/gemini-2.5-pro"] parent.provider_sort = "price" + parent.provider_require_parameters = True + parent.provider_data_collection = "deny" with patch("run_agent.AIAgent") as MockAgent: mock_child = MagicMock() @@ -1464,6 +1484,44 @@ class TestDelegationProviderIntegration(unittest.TestCase): self.assertIsNone(kwargs["providers_ignored"]) self.assertIsNone(kwargs["providers_order"]) self.assertIsNone(kwargs["provider_sort"]) + self.assertIs(kwargs["provider_require_parameters"], False) + self.assertEqual(kwargs["provider_data_collection"], "") + + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_same_provider_inherits_all_routing_preferences(self, mock_creds, mock_cfg): + mock_cfg.return_value = {"max_iterations": 45} + mock_creds.return_value = { + "model": None, + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + parent = _make_mock_parent(depth=0) + parent.provider = "nous" + parent.providers_allowed = ["deepseek"] + parent.providers_ignored = ["deepinfra"] + parent.providers_order = ["anthropic"] + parent.provider_sort = "throughput" + parent.provider_require_parameters = True + parent.provider_data_collection = "deny" + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.run_conversation.return_value = { + "final_response": "done", "completed": True, "api_calls": 1 + } + MockAgent.return_value = mock_child + delegate_task(goal="Keep routing", parent_agent=parent) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["providers_allowed"], ["deepseek"]) + self.assertEqual(kwargs["providers_ignored"], ["deepinfra"]) + self.assertEqual(kwargs["providers_order"], ["anthropic"]) + self.assertEqual(kwargs["provider_sort"], "throughput") + self.assertIs(kwargs["provider_require_parameters"], True) + self.assertEqual(kwargs["provider_data_collection"], "deny") @patch("tools.delegate_tool._load_config") @patch("tools.delegate_tool._resolve_delegation_credentials") diff --git a/tests/tools/test_discord_send_message_caption.py b/tests/tools/test_discord_send_message_caption.py new file mode 100644 index 00000000000..02e102904fd --- /dev/null +++ b/tests/tools/test_discord_send_message_caption.py @@ -0,0 +1,133 @@ +"""Discord standalone MEDIA: caption delivery. + +When `hermes send --to discord "MEDIA:/x.png This Caption"` targets a normal +(non-forum) channel, the caption must ride on the media message content rather +than being posted as a separate message before the attachment. The Discord REST +calls are mocked at the aiohttp.ClientSession boundary. +""" + +import asyncio +import json +import os +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +from plugins.platforms.discord.adapter import _remember_channel_is_forum, _standalone_send + + +def _resp(status, json_data=None, text_data=None): + r = AsyncMock() + r.status = status + body = json.dumps(json_data or {}).encode() if json_data is not None else (text_data or "").encode() + r.json = AsyncMock(return_value=json_data or {}) + r.text = AsyncMock(return_value=text_data or "") + # Discord's _standalone_read_*_limited helpers stream resp.content.read(); + # return the body once then EOF so the bounded reader terminates. AsyncMock + # with a list side_effect yields each element on successive awaits. + r.content = MagicMock() + r.content.read = AsyncMock(side_effect=[body, b"", b""]) + # _standalone_response_encoding calls resp.get_encoding() expecting a str; + # a bare AsyncMock would return a coroutine. Give it a plain callable. + r.get_encoding = MagicMock(return_value="utf-8") + return r + + +def _session_with(responses): + """Mocked aiohttp.ClientSession recording every POST (url, json, data).""" + calls = [] + idx = [0] + + def _post(url, **kwargs): + calls.append((url, kwargs.get("json"), kwargs.get("data"))) + r = responses[idx[0]] if idx[0] < len(responses) else responses[-1] + idx[0] += 1 + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=r) + ctx.__aexit__ = AsyncMock(return_value=False) + return ctx + + session = MagicMock() + session.post = MagicMock(side_effect=_post) + session_ctx = MagicMock() + session_ctx.__aenter__ = AsyncMock(return_value=session) + session_ctx.__aexit__ = AsyncMock(return_value=False) + return session_ctx, calls + + +def _pconfig(): + return SimpleNamespace(token="bot-token", extra={}) + + +def _tmpfile(suffix): + f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) + f.write(b"x") + f.close() + return f.name + + +def _payload_json_content(form_data): + """Extract the 'content' from a FormData's payload_json field, if any.""" + for field in getattr(form_data, "_fields", []): + # aiohttp FormData stores (type_options_dict, headers, value) + try: + type_opts = field[0] + value = field[2] + except (IndexError, TypeError): + continue + if type_opts.get("name") == "payload_json": + return json.loads(value).get("content") + return None + + +def test_caption_rides_media_non_forum(): + chat_id = "999000111" + _remember_channel_is_forum(chat_id, False) # avoid the live GET probe + img = _tmpfile(".png") + try: + session_ctx, calls = _session_with([_resp(200, {"id": "m1"})]) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + chat_id, + "", + media_files=[(img, False)], + caption="2-bedroom floor plan", + ) + ) + assert res["success"] is True + # Exactly one POST (the media upload) — no separate text message. + assert len(calls) == 1 + url, _json, data = calls[0] + assert url.endswith("/messages") + assert _payload_json_content(data) == "2-bedroom floor plan" + finally: + os.unlink(img) + + +def test_no_caption_non_forum_keeps_separate_text(): + """Without a caption, text + media are two separate POSTs (unchanged).""" + chat_id = "999000222" + _remember_channel_is_forum(chat_id, False) + img = _tmpfile(".png") + try: + session_ctx, calls = _session_with( + [_resp(200, {"id": "t1"}), _resp(200, {"id": "m1"})] + ) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + chat_id, + "hello", + media_files=[(img, False)], + ) + ) + assert res["success"] is True + # Two POSTs: the text content message, then the media upload. + assert len(calls) == 2 + assert calls[0][1] == {"content": "hello"} + assert calls[1][0].endswith("/messages") + finally: + os.unlink(img) diff --git a/tests/tools/test_execute_code_approval_cluster.py b/tests/tools/test_execute_code_approval_cluster.py index c5d7f3fb78c..7ea74a53b43 100644 --- a/tests/tools/test_execute_code_approval_cluster.py +++ b/tests/tools/test_execute_code_approval_cluster.py @@ -17,6 +17,7 @@ from __future__ import annotations import concurrent.futures import contextvars +import json import threading import pytest @@ -111,8 +112,10 @@ def gw_session(monkeypatch): monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) - # Force manual mode regardless of host config. + # Force manual mode regardless of host config and disable any process-level + # yolo inherited from the developer's live environment. monkeypatch.setattr(A, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", False) session_key = "cluster-test-session" token = A.set_current_session_key(session_key) @@ -142,6 +145,23 @@ def _register_resolver(session_key: str, result): A._gateway_notify_cbs[session_key] = cb +def _register_capturing_resolver(session_key: str, result): + """Resolve immediately and retain the exact approval payload shown.""" + seen = {} + + def cb(approval_data): + seen["approval_data"] = approval_data + with A._lock: + entries = A._gateway_queues.get(session_key, []) + if entries: + entries[-1].result = result + entries[-1].event.set() + + with A._lock: + A._gateway_notify_cbs[session_key] = cb + return seen + + def test_guard_isolated_backend_approved(): # Container backends already sandbox the child — no-op approve. assert A.check_execute_code_guard("import os", "docker")["approved"] is True @@ -158,6 +178,7 @@ def test_guard_headless_local_approved(monkeypatch): def test_guard_cron_deny_blocks(monkeypatch): + monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", False) monkeypatch.setenv("HERMES_CRON_SESSION", "1") monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) monkeypatch.setattr(A, "_get_approval_mode", lambda: "manual") @@ -231,11 +252,21 @@ def test_guard_gateway_user_denies_blocks(gw_session): assert res["user_consent"] is False -def test_guard_gateway_timeout_blocks(gw_session, monkeypatch): +@pytest.mark.parametrize( + "approval_config", + [ + {"timeout": 0}, + {"timeout": 0, "gateway_timeout": 300}, + ], + ids=["shared-timeout-only", "shared-timeout-is-canonical"], +) +def test_guard_gateway_wait_uses_canonical_timeout( + gw_session, monkeypatch, approval_config +): # Register a callback that never resolves; force an immediate timeout. with A._lock: A._gateway_notify_cbs[gw_session] = lambda _d: None - monkeypatch.setattr(A, "_get_approval_config", lambda: {"gateway_timeout": 0}) + monkeypatch.setattr(A, "_get_approval_config", lambda: approval_config) res = A.check_execute_code_guard("import os", "local") assert res["approved"] is False assert res["outcome"] == "timeout" @@ -255,9 +286,11 @@ def test_guard_smart_mode(gw_session, monkeypatch): res = A.check_execute_code_guard("import os", "local") assert res["approved"] is True and res.get("smart_approved") is True + # Smart DENY on an interactive surface now asks the owner. With no bound + # notifier it remains pending rather than being hard-denied. monkeypatch.setattr(A, "_smart_approve", lambda c, d: "deny") res = A.check_execute_code_guard("import os", "local") - assert res["approved"] is False and res.get("smart_denied") is True + assert res["approved"] is False and res["status"] == "pending_approval" # escalate → falls through to manual gateway approval monkeypatch.setattr(A, "_smart_approve", lambda c, d: "escalate") @@ -266,6 +299,150 @@ def test_guard_smart_mode(gw_session, monkeypatch): assert res["approved"] is True +def test_terminal_smart_deny_owner_override_is_one_operation(gw_session, monkeypatch): + """A human may override DENY, but a broad UI choice must not be persisted.""" + with A._lock: + A._permanent_approved.discard("owner-override-test-danger") + A._session_approved.get(gw_session, set()).discard("owner-override-test-danger") + monkeypatch.setattr(A, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(A, "_smart_approve", lambda _command, _description: "deny") + monkeypatch.setattr( + A, + "detect_dangerous_command", + lambda command: (True, "owner-override-test-danger", f"risk:{command}"), + ) + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _command: {"action": "allow", "findings": [], "summary": ""}, + raising=False, + ) + + shown = _register_capturing_resolver(gw_session, "always") + result = A.check_all_command_guards("dangerous /tmp/first", "local") + + assert result["approved"] is True + assert result["user_approved"] is True + assert shown["approval_data"]["smart_denied"] is True + assert shown["approval_data"]["allow_permanent"] is False + assert A.is_approved(gw_session, "owner-override-test-danger") is False + + _register_resolver(gw_session, "deny") + changed = A.check_all_command_guards("dangerous /tmp/second", "local") + assert changed["approved"] is False + assert changed["outcome"] == "denied" + + +def test_execute_code_smart_deny_owner_override_is_one_operation(gw_session, monkeypatch): + """Never persist the coarse execute_code key after overriding smart DENY.""" + with A._lock: + A._permanent_approved.discard("execute_code") + A._session_approved.get(gw_session, set()).discard("execute_code") + monkeypatch.setattr(A, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(A, "_smart_approve", lambda _command, _description: "deny") + + shown = _register_capturing_resolver(gw_session, "session") + result = A.check_execute_code_guard("print('first')", "local") + + assert result["approved"] is True + assert result["user_approved"] is True + assert shown["approval_data"]["smart_denied"] is True + assert shown["approval_data"]["allow_permanent"] is False + assert A.is_approved(gw_session, "execute_code") is False + + _register_resolver(gw_session, "deny") + changed = A.check_execute_code_guard("print('second')", "local") + assert changed["approved"] is False + assert changed["outcome"] == "denied" + + +def test_smart_escalate_still_persists_session_choice(gw_session, monkeypatch): + """The DENY restriction must not alter Smart ESCALATE's manual choices.""" + key = "smart-escalate-persistence" + with A._lock: + A._session_approved.get(gw_session, set()).discard(key) + monkeypatch.setattr(A, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(A, "_smart_approve", lambda _command, _description: "escalate") + monkeypatch.setattr( + A, "detect_dangerous_command", + lambda command: (True, key, f"risk:{command}"), + ) + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _command: {"action": "allow", "findings": [], "summary": ""}, + raising=False, + ) + + shown = _register_capturing_resolver(gw_session, "session") + result = A.check_all_command_guards("dangerous escalate", "local") + + assert result["approved"] is True + assert shown["approval_data"]["allow_permanent"] is True + assert "smart_denied" not in shown["approval_data"] + assert A.is_approved(gw_session, key) is True + + +def test_terminal_smart_deny_pending_payload_is_one_operation(gw_session, monkeypatch): + monkeypatch.setattr(A, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(A, "_smart_approve", lambda _command, _description: "deny") + monkeypatch.setattr( + A, "detect_dangerous_command", + lambda command: (True, "pending-smart-deny", f"risk:{command}"), + ) + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _command: {"action": "allow", "findings": [], "summary": ""}, + raising=False, + ) + + result = A.check_all_command_guards("dangerous pending", "local") + + assert result["status"] == "pending_approval" + assert result["smart_denied"] is True + assert result["allow_permanent"] is False + with A._lock: + pending = dict(A._pending[gw_session]) + assert pending["smart_denied"] is True + assert pending["allow_permanent"] is False + + +def test_execute_code_smart_deny_pending_payload_is_one_operation(gw_session, monkeypatch): + monkeypatch.setattr(A, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(A, "_smart_approve", lambda _command, _description: "deny") + + result = A.check_execute_code_guard("print('pending')", "local") + + assert result["status"] == "pending_approval" + assert result["smart_denied"] is True + assert result["allow_permanent"] is False + with A._lock: + pending = dict(A._pending[gw_session]) + assert pending["smart_denied"] is True + assert pending["allow_permanent"] is False + + +def test_terminal_serializes_smart_deny_pending_capabilities(monkeypatch): + from tools import terminal_tool as terminal_module + + monkeypatch.setattr( + terminal_module, + "_check_all_guards", + lambda *_args, **_kwargs: { + "approved": False, + "status": "pending_approval", + "command": "rm -rf /tmp/example", + "description": "recursive delete", + "pattern_key": "rm-rf", + "smart_denied": True, + "allow_permanent": False, + }, + ) + + payload = json.loads(terminal_module.terminal_tool(command="rm -rf /tmp/example")) + + assert payload["smart_denied"] is True + assert payload["allow_permanent"] is False + + def test_guard_session_yolo_bypasses(gw_session): A.enable_session_yolo(gw_session) try: @@ -333,6 +510,7 @@ def test_execute_code_entry_blocks_before_spawn_when_guard_denies(monkeypatch, t from tools import terminal_tool as TT marker = tmp_path / "child-ran.marker" + monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", False) monkeypatch.setenv("HERMES_CRON_SESSION", "1") monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) diff --git a/tests/tools/test_file_operations.py b/tests/tools/test_file_operations.py index eda5140b016..3b2c0ce5932 100644 --- a/tests/tools/test_file_operations.py +++ b/tests/tools/test_file_operations.py @@ -467,6 +467,61 @@ 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_escape_shell_arg_normalizes_mixed_msys_paths(self, monkeypatch, file_ops): + import tools.environments.local as local_mod + + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + mixed = r"/c/Users/Alexander\Documents\NewTEST\readme.txt" + assert file_ops._escape_shell_arg(mixed) == ( + "'/c/Users/Alexander/Documents/NewTEST/readme.txt'" + ) + + def test_escape_shell_arg_rewrites_forward_slash_native_paths(self, monkeypatch, file_ops): + import tools.environments.local as local_mod + + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert file_ops._escape_shell_arg( + "C:/Users/alice/notes.txt" + ) == "'/c/Users/alice/notes.txt'" + + 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 diff --git a/tests/tools/test_file_tools.py b/tests/tools/test_file_tools.py index 36918417938..f57d5282103 100644 --- a/tests/tools/test_file_tools.py +++ b/tests/tools/test_file_tools.py @@ -427,6 +427,69 @@ class TestSearchHandler: assert "error" in result +# --------------------------------------------------------------------------- +# Windows MSYS path resolution (salvage of #50488 / #46995) +# --------------------------------------------------------------------------- + +class TestWindowsMsysPathResolution: + """File tools must translate Git Bash drive paths before Path resolution.""" + + def test_absolute_msys_path_normalized_before_windows_resolve(self, monkeypatch): + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": False) + + resolved = file_tools._resolve_path_for_task("/c/Users/Mark/project/app.py") + assert str(resolved) == r"C:\Users\Mark\project\app.py" + + def test_cygdrive_path_normalized(self, monkeypatch): + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": False) + + resolved = file_tools._resolve_path_for_task("/cygdrive/d/code/main.py") + assert str(resolved) == r"D:\code\main.py" + + def test_relative_path_uses_normalized_msys_cwd(self, monkeypatch): + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": False) + monkeypatch.setattr( + file_tools, + "_authoritative_workspace_root", + lambda task_id="default": "/c/Users/Mark/project", + ) + + resolved = file_tools._resolve_path_for_task("src/app.py", task_id="msys") + assert str(resolved) == r"C:\Users\Mark\project\src\app.py" + + def test_container_paths_skip_msys_translation(self, monkeypatch): + """WSL/docker Linux paths must not be rewritten as Windows drives.""" + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": True) + monkeypatch.setattr( + file_tools, + "_authoritative_workspace_root", + lambda task_id="default": "/home/don/project", + ) + + resolved = file_tools._resolve_path_for_task("/home/don/.env") + assert str(resolved) == "/home/don/.env" + + # --------------------------------------------------------------------------- # Tool result hint tests (#722) # --------------------------------------------------------------------------- diff --git a/tests/tools/test_find_shell.py b/tests/tools/test_find_shell.py index 6de3b2594f9..b4bce600315 100644 --- a/tests/tools/test_find_shell.py +++ b/tests/tools/test_find_shell.py @@ -116,6 +116,33 @@ class TestFindBashUnchanged: assert len(result) > 0 +class TestFindBashSkipsBrokenCustomPath: + """Stale HERMES_GIT_BASH_PATH must not brick Windows terminal startup.""" + + def test_falls_through_to_portable_when_custom_fails_probe(self, tmp_path, monkeypatch): + import tools.environments.local as local_mod + + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + local_mod._bash_starts_cache.clear() + + broken = tmp_path / "broken" / "bash.exe" + broken.parent.mkdir() + broken.write_text("", encoding="utf-8") + portable = tmp_path / "hermes" / "git" / "bin" / "bash.exe" + portable.parent.mkdir(parents=True) + portable.write_text("", encoding="utf-8") + + monkeypatch.setenv("HERMES_GIT_BASH_PATH", str(broken)) + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + + def fake_starts(path: str) -> bool: + return path == str(portable) + + monkeypatch.setattr(local_mod, "_bash_starts", fake_starts) + + assert _find_bash() == str(portable) + + @pytest.mark.skipif( not os.path.isfile("/bin/bash") or sys.platform != "darwin", reason="reproduces the macOS system-bash-3.2 login-shell swallow", diff --git a/tests/tools/test_image_generation_plugin_dispatch.py b/tests/tools/test_image_generation_plugin_dispatch.py index fa8ca9d959c..f96da8d64df 100644 --- a/tests/tools/test_image_generation_plugin_dispatch.py +++ b/tests/tools/test_image_generation_plugin_dispatch.py @@ -97,3 +97,28 @@ class TestPluginDispatch: assert payload["success"] is True assert payload["provider"] == "codex" assert payload["aspect_ratio"] == "portrait" + + def test_unset_provider_keeps_legacy_fal_path(self, monkeypatch): + """An unrelated API key must not opt the user into paid image generation.""" + from tools import image_generation_tool + + monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: None) + assert image_generation_tool._dispatch_to_plugin_provider("draw cat", "landscape") is None + + def test_deepinfra_key_alone_does_not_select_image_backend(self, monkeypatch): + """DeepInfra chat credentials do not imply consent to image billing.""" + from tools import image_generation_tool + + monkeypatch.setenv("DEEPINFRA_API_KEY", "«redacted:sk-…»") + monkeypatch.delenv("FAL_KEY", raising=False) + monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: None) + assert image_generation_tool._dispatch_to_plugin_provider("a cat", "square") is None + + def test_requirements_ignore_unselected_paid_plugin(self, monkeypatch): + from tools import image_generation_tool + + monkeypatch.setattr(image_generation_tool, "check_fal_api_key", lambda: False) + monkeypatch.setattr( + image_generation_tool, "_read_configured_image_provider", lambda: None + ) + assert image_generation_tool.check_image_generation_requirements() is False diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index cdf8c4cdb5d..5b1d9f6f755 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -484,6 +484,31 @@ def test_complete_rejects_non_list_artifacts(worker_env): assert "artifacts must be a list" in err +def test_complete_missing_scratch_artifact_stays_in_flight(worker_env): + """A false deliverable claim must return retry guidance, not mark Done.""" + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + with kb.connect() as conn: + task = kb.get_task(conn, worker_env) + assert task is not None + workspace = kb.resolve_workspace(task) + kb.set_workspace_path(conn, worker_env, workspace) + + output = kt._handle_complete({ + "summary": "report complete", + "artifacts": [str(workspace / "missing-report.md")], + }) + error = json.loads(output).get("error", "") + + assert "could not preserve" in error + assert "still in-flight" in error + assert "retry kanban_complete" in error + with kb.connect() as conn: + assert kb.get_task(conn, worker_env).status == "running" + assert workspace.exists() + + def test_complete_rejects_no_handoff(worker_env): from tools import kanban_tools as kt out = kt._handle_complete({}) diff --git a/tests/tools/test_local_env_windows_msys.py b/tests/tools/test_local_env_windows_msys.py index 59f01ac56b6..f5eba3f252a 100644 --- a/tests/tools/test_local_env_windows_msys.py +++ b/tests/tools/test_local_env_windows_msys.py @@ -18,14 +18,19 @@ and ``os.path.isdir`` so the MSYS path tests as "missing" exactly like on the real OS. """ +import os from unittest.mock import patch - +from tools.environments.base import BaseEnvironment from tools.environments import local as local_mod from tools.environments.local import ( LocalEnvironment, + _bash_safe_path, + _git_bash_bin_dirs, _make_run_env, _msys_to_windows_path, + _prepend_git_bash_dirs, + _quote_bash_path, _resolve_safe_cwd, _sanitize_subprocess_env, _windows_to_msys_path, @@ -68,6 +73,15 @@ class TestMsysToWindowsPath: monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) assert _msys_to_windows_path("/tmp/foo") == "/tmp/foo" assert _msys_to_windows_path("/home/x") == "/home/x" + # /mnt//... only translates when is a single drive letter. + assert _msys_to_windows_path("/mnt/home/x") == "/mnt/home/x" + + def test_translates_cygdrive_and_wsl_mnt_forms(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert _msys_to_windows_path("/cygdrive/c/Users/NVIDIA") == r"C:\Users\NVIDIA" + assert _msys_to_windows_path("/mnt/d/Projects/foo") == r"D:\Projects\foo" + assert _msys_to_windows_path("/cygdrive/c") == "C:\\" + assert _msys_to_windows_path("/mnt/c/") == "C:\\" def test_empty_string(self, monkeypatch): monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) @@ -103,6 +117,42 @@ class TestWindowsToMsysPath: assert _windows_to_msys_path(r"\\server\share") == r"\\server\share" +# --------------------------------------------------------------------------- +# _bash_safe_path / _quote_bash_path — shell-script interpolation +# --------------------------------------------------------------------------- + +class TestBashSafePath: + def test_native_windows_path_becomes_msys(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert _bash_safe_path(r"C:\Users\alice\notes.txt") == "/c/Users/alice/notes.txt" + + def test_forward_slash_native_path_becomes_msys(self, monkeypatch): + """Production get_temp_dir emits C:/... — still needs /c/... rewrite.""" + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert ( + _bash_safe_path("C:/Users/Alexander/.hermes/cache/terminal/hermes-snap-x.sh") + == "/c/Users/Alexander/.hermes/cache/terminal/hermes-snap-x.sh" + ) + + def test_mixed_msys_path_normalizes_backslashes(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + mixed = r"/c/Users/Alexander\Documents\NewTEST\readme.txt" + assert _bash_safe_path(mixed) == "/c/Users/Alexander/Documents/NewTEST/readme.txt" + + def test_noop_off_windows(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", False) + path = r"/c/Users\Alexander\Documents" + assert _bash_safe_path(path) == path + + def test_quote_bash_path_quotes_mixed_windows_path(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + quoted = _quote_bash_path( + r"C:\Users\Alexander\AppData\Local\Temp\hermes-snap-abc.sh" + ) + assert "/c/Users/Alexander/AppData/Local/Temp/hermes-snap-abc.sh" in quoted + assert "\\" not in quoted + + # --------------------------------------------------------------------------- # _resolve_safe_cwd — Windows fast path # --------------------------------------------------------------------------- @@ -278,6 +328,88 @@ class TestWindowsMsysPathconvDefaults: assert run_env.get("MSYS2_ARG_CONV_EXCL") == "/custom" +# --------------------------------------------------------------------------- +# Git Bash coreutils on PATH — non-login ``bash -c`` fallback (empty +# write_file error / terminal exit 127 when login bash is broken) +# --------------------------------------------------------------------------- + +class TestGitBashCoreutilsOnPath: + def _fake_isdir(self, existing): + existing = {e.replace("\\", "/") for e in existing} + return lambda p: p.replace("\\", "/") in existing + + def test_derives_dirs_from_portablegit_layout(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + monkeypatch.setattr(local_mod, "_find_bash", lambda: "/pg/bin/bash.exe") + existing = {"/pg/mingw64/bin", "/pg/usr/bin", "/pg/bin"} + monkeypatch.setattr(local_mod.os.path, "isdir", self._fake_isdir(existing)) + + dirs = _git_bash_bin_dirs() + + # usr/bin is the load-bearing coreutils dir; mingw64 precedes it. + assert "/pg/usr/bin" in dirs + assert dirs.index("/pg/mingw64/bin") < dirs.index("/pg/usr/bin") + # Non-existent dirs (mingw32, usr/local/bin) are excluded. + assert "/pg/mingw32/bin" not in dirs + + def test_derives_dirs_from_mingit_usr_bin_layout(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + monkeypatch.setattr(local_mod, "_find_bash", lambda: "/mg/usr/bin/bash.exe") + existing = {"/mg/usr/bin", "/mg/mingw64/bin"} + monkeypatch.setattr(local_mod.os.path, "isdir", self._fake_isdir(existing)) + + dirs = _git_bash_bin_dirs() + + # MinGit ships bash under usr\bin; root must still resolve to /mg. + assert "/mg/usr/bin" in dirs + assert "/mg/mingw64/bin" in dirs + + def test_empty_off_windows(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", False) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + assert _git_bash_bin_dirs() == [] + + def test_empty_when_bash_unresolvable(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + + def boom(): + raise RuntimeError("Git Bash not found") + + monkeypatch.setattr(local_mod, "_find_bash", boom) + assert _git_bash_bin_dirs() == [] + + def test_prepend_is_idempotent(self, monkeypatch): + # Simulate Windows' ``;`` separator so drive-letter colons in fake + # paths don't collide with the POSIX ``:`` pathsep on the test host. + monkeypatch.setattr(os, "pathsep", ";") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", ["/pg/usr/bin", "/pg/bin"]) + already = r"/pg/usr/bin;C:\Windows\System32;/pg/bin" + assert _prepend_git_bash_dirs(already) == already + + def test_make_run_env_prepends_coreutils_on_windows(self, monkeypatch): + monkeypatch.setattr(os, "pathsep", ";") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", ["/pg/mingw64/bin", "/pg/usr/bin"]) + run_env = _make_run_env({"PATH": r"C:\Windows\System32"}) + path = run_env.get("PATH") or run_env.get("Path") + entries = path.split(";") + # Coreutils dirs land before System32 so bash resolves cat/find/sort + # to the GNU tools, not the same-named Windows executables. + assert "/pg/usr/bin" in entries + assert entries.index("/pg/usr/bin") < entries.index(r"C:\Windows\System32") + + def test_make_run_env_noop_on_posix(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", False) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + run_env = _make_run_env({"PATH": "/usr/bin:/bin"}) + # No Windows git dirs injected on POSIX. + assert "mingw64" not in run_env["PATH"] + + # --------------------------------------------------------------------------- # Command wrapping — native Windows cwd must be Git Bash-friendly for cd # --------------------------------------------------------------------------- @@ -306,7 +438,7 @@ class TestWrapCommandWindowsNativeCwd: captured = {} def fake_run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None): - captured["script"] = cmd_string + captured.setdefault("script", cmd_string) # bootstrap only; ignore the failure-path probe raise RuntimeError("stop after capturing bootstrap") monkeypatch.setattr(LocalEnvironment, "_run_bash", fake_run_bash) @@ -317,3 +449,61 @@ class TestWrapCommandWindowsNativeCwd: assert "builtin cd -- /c/Users/liush 2>/dev/null || true" in captured["script"] assert r"C:\Users\liush" not in captured["script"] + + def test_init_session_bootstrap_quotes_snapshot_paths_in_msys_form(self, monkeypatch): + """Snapshot paths must reach bash as /c/... — C:/... still trips MSYS + arg conversion during bash -l and surfaces as \\drivers\\etc.""" + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + + captured = {} + + def fake_run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None): + captured.setdefault("script", cmd_string) # bootstrap only; ignore the failure-path probe + raise RuntimeError("stop after capturing bootstrap") + + monkeypatch.setattr(LocalEnvironment, "_run_bash", fake_run_bash) + + # Production shape: get_temp_dir forces forward slashes but keeps C:. + snap = "C:/Users/Alexander/.hermes/cache/terminal/hermes-snap-deadbeef.sh" + with patch.object(LocalEnvironment, "__init__", lambda self, **kw: None): + env = LocalEnvironment.__new__(LocalEnvironment) + BaseEnvironment.__init__( + env, + cwd=r"C:\Users\Alexander\Documents", + timeout=10, + ) + env._snapshot_path = snap + env._cwd_file = snap + ".cwd" + env.init_session() + + script = captured["script"] + assert "/c/Users/Alexander/.hermes/cache/terminal/hermes-snap-deadbeef.sh" in script + assert "C:/Users/Alexander" not in script + assert r"C:\Users\Alexander" not in script + + def test_init_session_bootstrap_rewrites_backslash_snapshot_paths(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + + captured = {} + + def fake_run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None): + captured.setdefault("script", cmd_string) # bootstrap only; ignore the failure-path probe + raise RuntimeError("stop after capturing bootstrap") + + monkeypatch.setattr(LocalEnvironment, "_run_bash", fake_run_bash) + + snap = r"C:\Users\Alexander\AppData\Local\Temp\hermes-snap-deadbeef.sh" + with patch.object(LocalEnvironment, "__init__", lambda self, **kw: None): + env = LocalEnvironment.__new__(LocalEnvironment) + BaseEnvironment.__init__( + env, + cwd=r"C:\Users\Alexander\Documents", + timeout=10, + ) + env._snapshot_path = snap + env._cwd_file = snap + ".cwd" + env.init_session() + + script = captured["script"] + assert "/c/Users/Alexander/AppData/Local/Temp/hermes-snap-deadbeef.sh" in script + assert r"C:\Users\Alexander\AppData" not in script diff --git a/tests/tools/test_media_caption_split.py b/tests/tools/test_media_caption_split.py new file mode 100644 index 00000000000..a4a1c795317 --- /dev/null +++ b/tests/tools/test_media_caption_split.py @@ -0,0 +1,115 @@ +"""Guard test for the MEDIA: caption chokepoint (_media_caption_split). + +`hermes send` strips the MEDIA: tag and leaves the remaining prose as the +accompanying text. Historically every standalone sender posted that text as a +*separate* message before an uncaptioned media bubble, splitting +``hermes send --to whatsapp "MEDIA:/x.png This Caption"`` into two parts. + +`_media_caption_split` is the single enforced decision point that all standalone +senders (WhatsApp, Telegram, Discord) consult to decide whether the text should +ride on the media bubble as a native caption. This test pins that contract so +the platforms can't diverge. +""" + +from tools.send_message_tool import ( + _DEFAULT_CAPTION_LIMIT, + _TELEGRAM_CAPTION_LIMIT, + _media_caption_split, +) + + +def test_single_image_short_text_becomes_caption(): + caption, body = _media_caption_split( + "This Caption", [("/tmp/F22.png", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption == "This Caption" + assert body == "" + + +def test_single_video_short_text_becomes_caption(): + caption, body = _media_caption_split( + "Model unit tour", [("/tmp/tour.mp4", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption == "Model unit tour" + assert body == "" + + +def test_single_document_short_text_becomes_caption(): + caption, body = _media_caption_split( + "Q3 report", [("/tmp/report.pdf", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption == "Q3 report" + assert body == "" + + +def test_multi_file_keeps_separate_body(): + text = "two photos" + caption, body = _media_caption_split( + text, + [("/tmp/a.png", False), ("/tmp/b.png", False)], + max_caption_len=_DEFAULT_CAPTION_LIMIT, + ) + assert caption is None + assert body == text + + +def test_voice_note_keeps_separate_body(): + text = "listen to this" + caption, body = _media_caption_split( + text, [("/tmp/note.ogg", True)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption is None + assert body == text + + +def test_empty_text_no_caption(): + caption, body = _media_caption_split( + " ", [("/tmp/a.png", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption is None + # body is returned unchanged (still whitespace) — sender's own guards drop it + assert body == " " + + +def test_no_media_no_caption(): + caption, body = _media_caption_split( + "hello", [], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption is None + assert body == "hello" + + +def test_text_over_limit_stays_separate_body(): + long_text = "x" * (_TELEGRAM_CAPTION_LIMIT + 1) + caption, body = _media_caption_split( + long_text, [("/tmp/a.png", False)], max_caption_len=_TELEGRAM_CAPTION_LIMIT + ) + assert caption is None + assert body == long_text + + +def test_text_at_limit_still_captions(): + text = "y" * _TELEGRAM_CAPTION_LIMIT + caption, body = _media_caption_split( + text, [("/tmp/a.png", False)], max_caption_len=_TELEGRAM_CAPTION_LIMIT + ) + assert caption == text + assert body == "" + + +def test_caption_is_stripped(): + caption, body = _media_caption_split( + " padded caption ", [("/tmp/a.png", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption == "padded caption" + assert body == "" + + +def test_unknown_extension_keeps_separate_body(): + # A non-captionable kind (e.g. an audio note that isn't flagged voice) + text = "some audio" + caption, body = _media_caption_split( + text, [("/tmp/song.mp3", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption is None + assert body == text diff --git a/tests/tools/test_modal_sandbox_fixes.py b/tests/tools/test_modal_sandbox_fixes.py index 3bccb3e07d1..dddfe134edb 100644 --- a/tests/tools/test_modal_sandbox_fixes.py +++ b/tests/tools/test_modal_sandbox_fixes.py @@ -350,6 +350,7 @@ class TestDockerHostBindApproval: def test_isolated_docker_keeps_fast_path(self, monkeypatch): """Isolated Docker still bypasses dangerous-command approval.""" import tools.approval as A + self._isolate_approval_state(monkeypatch) monkeypatch.setenv("HERMES_EXEC_ASK", "1") monkeypatch.setattr( "tools.tirith_security.check_command_security", @@ -358,9 +359,26 @@ class TestDockerHostBindApproval: has_host_access=False) assert res["approved"] is True + @staticmethod + def _isolate_approval_state(monkeypatch): + """Clear approval state that leaks in from the real user config. + + ``tools.approval`` loads ``command_allowlist`` into module-level + ``_permanent_approved`` at import time. This file imports + ``tools.terminal_tool`` at module level (collection time — BEFORE the + hermetic HERMES_HOME fixture runs), so on a dev machine whose real + config permanently allowlists e.g. "delete in root path" the guard + under test silently approves and the assertions flip. CI never has + such an allowlist, making this a local-only flake. + """ + import tools.approval as A + monkeypatch.setattr(A, "_permanent_approved", set()) + monkeypatch.setattr(A, "_session_approved", {}) + def test_host_bound_docker_requires_approval(self, monkeypatch): """Host-bound Docker dangerous command escalates instead of bypassing.""" import tools.approval as A + self._isolate_approval_state(monkeypatch) monkeypatch.setenv("HERMES_EXEC_ASK", "1") monkeypatch.setattr( "tools.tirith_security.check_command_security", @@ -374,6 +392,7 @@ class TestDockerHostBindApproval: def test_execute_code_isolated_docker_keeps_fast_path(self, monkeypatch): """Isolated Docker execute_code still bypasses the guard.""" import tools.approval as A + self._isolate_approval_state(monkeypatch) monkeypatch.setenv("HERMES_EXEC_ASK", "1") res = A.check_execute_code_guard("import os", "docker", has_host_access=False) @@ -382,6 +401,7 @@ class TestDockerHostBindApproval: def test_execute_code_host_bound_docker_requires_approval(self, monkeypatch): """Host-bound Docker execute_code does not get the container fast-path.""" import tools.approval as A + self._isolate_approval_state(monkeypatch) monkeypatch.setenv("HERMES_EXEC_ASK", "1") res = A.check_execute_code_guard( "import os; os.system('rm -rf /workspace')", "docker", diff --git a/tests/tools/test_patch_parser.py b/tests/tools/test_patch_parser.py index 79077a84a16..52cca09fa37 100644 --- a/tests/tools/test_patch_parser.py +++ b/tests/tools/test_patch_parser.py @@ -401,6 +401,74 @@ class TestValidationPhase: assert result.success is True assert set(written.keys()) == {"a.py", "b.py"} + def test_context_only_hunk_does_not_reject_later_real_hunk(self): + patch = """\ +*** Begin Patch +*** Update File: a.py +@@ anchor @@ + anchor +@@ value @@ +-value = 1 ++value = 2 +*** End Patch""" + ops, err = parse_v4a_patch(patch) + assert err is None + + class FakeFileOps: + written = None + def read_file_raw(self, path): + return SimpleNamespace(content="anchor\nvalue = 1\n", error=None) + def write_file(self, path, content): + self.written = content + return SimpleNamespace(error=None) + + file_ops = FakeFileOps() + result = apply_v4a_operations(ops, file_ops) + assert result.success is True + assert file_ops.written == "anchor\nvalue = 2\n" + + def test_patch_with_only_context_hunks_reports_no_changes(self): + patch = """\ +*** Begin Patch +*** Update File: a.py +@@ anchor @@ + anchor +*** End Patch""" + ops, err = parse_v4a_patch(patch) + assert err is None + + class FakeFileOps: + def read_file_raw(self, path): + return SimpleNamespace(content="anchor\n", error=None) + def write_file(self, path, content): + raise AssertionError("no-op patch must not write") + + result = apply_v4a_operations(ops, FakeFileOps()) + assert result.success is False + assert "no changes" in result.error.lower() + + def test_validation_error_identifies_hunk_number(self): + patch = """\ +*** Begin Patch +*** Update File: a.py +@@ first @@ +-first = 1 ++first = 2 +@@ missing @@ +-does_not_exist = 1 ++does_not_exist = 2 +*** End Patch""" + ops, err = parse_v4a_patch(patch) + assert err is None + + class FakeFileOps: + def read_file_raw(self, path): + return SimpleNamespace(content="first = 1\n", error=None) + + result = apply_v4a_operations(ops, FakeFileOps()) + assert result.success is False + assert "hunk 2" in result.error.lower() + class TestApplyDelete: """Tests for _apply_delete producing a real unified diff.""" diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index 4d8b56556d7..49d1193cf67 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -47,6 +47,70 @@ class TestRegisterAndDispatch: result = json.loads(reg.dispatch("echo", {"msg": "hi"})) assert result == {"msg": "hi"} + def test_dispatch_preserves_supported_multimodal_result(self): + reg = ToolRegistry() + multimodal = { + "_multimodal": True, + "content": [{"type": "text", "text": "captured"}], + "text_summary": "captured", + } + reg.register( + name="capture", + toolset="computer_use", + schema=_make_schema("capture"), + handler=lambda args, **kw: multimodal, + ) + + assert reg.dispatch("capture", {}) is multimodal + + def test_dispatch_rejects_unsupported_handler_results_with_structured_error(self): + invalid_results = ({"ok": True}, b"bytes", None, 42) + + for invalid in invalid_results: + reg = ToolRegistry() + reg.register( + name="bad_result", + toolset="core", + schema=_make_schema("bad_result"), + handler=lambda args, _invalid=invalid, **kw: _invalid, + ) + + raw = reg.dispatch("bad_result", {}) + result = json.loads(raw) + + assert isinstance(raw, str) + assert result["error_type"] == "tool_result_contract" + assert result["tool"] == "bad_result" + assert result["result_type"] == type(invalid).__name__ + assert "unsupported result type" in result["error"] + + def test_handler_contract_error_survives_model_tools_pipeline(self): + from model_tools import handle_function_call, registry + + name = "test_invalid_registry_result" + registry.register( + name=name, + toolset="core", + schema=_make_schema(name), + handler=lambda args, **kw: None, + ) + try: + raw = handle_function_call( + name, + {}, + task_id="contract-test", + skip_pre_tool_call_hook=True, + ) + finally: + registry.deregister(name) + + result = json.loads(raw) + assert len(raw) > 0 # downstream sizing/logging remains safe + assert json.loads(json.dumps({"content": raw}))["content"] == raw + assert result["error_type"] == "tool_result_contract" + assert result["tool"] == name + assert result["result_type"] == "NoneType" + class TestGetDefinitions: def test_returns_openai_format(self): diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index ed042332263..55c5165901b 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -146,11 +146,14 @@ class _patch_discord_sender: self._entry = None self._original = None - async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, media_files=None): + async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, media_files=None, caption=None): token = getattr(pconfig, "token", None) + # Only forward caption= when set, so mocks written against the + # pre-caption signature (no caption kwarg) keep working. + extra = {"caption": caption} if caption is not None else {} return await self._mock( token, chat_id, message, - thread_id=thread_id, media_files=media_files, + thread_id=thread_id, media_files=media_files, **extra, ) def __enter__(self): @@ -595,7 +598,9 @@ class TestSendMessageTool: class TestSendTelegramMediaDelivery: - def test_sends_text_then_photo_for_media_tag(self, tmp_path, monkeypatch): + def test_sends_photo_with_caption_for_media_tag(self, tmp_path, monkeypatch): + # A single captionable image + short text now rides as the photo's + # native caption (MEDIA: caption), not a separate text message. image_path = tmp_path / "photo.png" image_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32) @@ -619,11 +624,10 @@ class TestSendTelegramMediaDelivery: assert result["success"] is True assert result["message_id"] == "2" - bot.send_message.assert_awaited_once() + # No separate text send — the caption rides the photo bubble. + bot.send_message.assert_not_awaited() bot.send_photo.assert_awaited_once() - sent_text = bot.send_message.await_args.kwargs["text"] - assert "MEDIA:" not in sent_text - assert sent_text == "Hello there" + assert bot.send_photo.await_args.kwargs.get("caption") == "Hello there" def test_sends_voice_for_ogg_with_voice_directive(self, tmp_path, monkeypatch): voice_path = tmp_path / "voice.ogg" @@ -2048,7 +2052,7 @@ class TestSendToPlatformDiscordMedia: assert call_log[1]["media_files"] == [("/fake/img.png", False)] # Last chunk: media attached def test_single_chunk_gets_media(self): - """Short message (single chunk) gets media_files directly.""" + """Short message + single image rides as the media caption.""" send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) with _patch_discord_sender(send_mock): @@ -2066,6 +2070,9 @@ class TestSendToPlatformDiscordMedia: send_mock.assert_awaited_once() call_kwargs = send_mock.await_args.kwargs assert call_kwargs["media_files"] == [("/fake/img.png", False)] + # Text rides as the caption, not a separate positional message body. + assert call_kwargs.get("caption") == "short message" + assert send_mock.await_args.args[2] == "" class TestSendMatrixUrlEncoding: @@ -3334,13 +3341,17 @@ class TestSendTelegramThreadNotFoundRetry: "retry should drop message_thread_id after thread-not-found" def test_disable_web_page_preview_not_leaked_to_media_sends(self): - """disable_web_page_preview should only appear in text send, not media sends.""" - text_kwargs_seen = [] + """disable_web_page_preview must never leak into a media send. + + A single captionable file + short text now rides as the document's + caption (no separate text send), so the invariant to protect is that + the captioned send_document does not inherit disable_web_page_preview + (valid only for send_message). + """ media_kwargs_seen = [] class FakeBot: async def send_message(self, **kwargs): - text_kwargs_seen.append(kwargs) return SimpleNamespace(message_id=1) async def send_document(self, **kwargs): @@ -3364,9 +3375,9 @@ class TestSendTelegramThreadNotFoundRetry: result = asyncio.run(run_test()) assert result["success"] is True - # Text send should have disable_web_page_preview - assert text_kwargs_seen[0].get("disable_web_page_preview") is True - # Media send should NOT have disable_web_page_preview + # Caption rides the document bubble. + assert media_kwargs_seen[0].get("caption") == "check preview" + # Media send must NOT carry disable_web_page_preview. assert "disable_web_page_preview" not in media_kwargs_seen[0], \ "disable_web_page_preview leaked into send_document kwargs" finally: diff --git a/tests/tools/test_skill_bundle_provenance.py b/tests/tools/test_skill_bundle_provenance.py new file mode 100644 index 00000000000..33878b3f9ca --- /dev/null +++ b/tests/tools/test_skill_bundle_provenance.py @@ -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"} diff --git a/tests/tools/test_skills_tool_discovery_cache.py b/tests/tools/test_skills_tool_discovery_cache.py new file mode 100644 index 00000000000..55908118cfa --- /dev/null +++ b/tests/tools/test_skills_tool_discovery_cache.py @@ -0,0 +1,129 @@ +"""Regression tests for the _find_all_skills discovery cache (#58985 salvage). + +Covers the cache-signature fix layered on the cherry-picked contributor +commit: the original keyed the cache on the max mtime of only the TOP-LEVEL +scan dirs, so adding/removing a skill inside a category subdir (which bumps +the category dir's mtime, not the root's) served a stale list indefinitely. +The signature now covers roots + immediate children (mirroring +hermes_cli/profiles.py::_count_skills) plus the disabled-set, with a short +TTL bounding in-place SKILL.md edit staleness. +""" + +import time + +import pytest + +import tools.skills_tool as st + + +@pytest.fixture(autouse=True) +def _fresh_cache(monkeypatch, tmp_path): + """Isolate every test: clear the module cache and point the scan at + an empty external-dirs list + a tmp skills root.""" + st._SKILLS_CACHE.clear() + monkeypatch.setattr(st, "_skills_dir", lambda: tmp_path / "skills") + monkeypatch.setattr( + "agent.skill_utils.get_external_skills_dirs", lambda: [] + ) + monkeypatch.setattr(st, "_get_disabled_skill_names", lambda: set()) + yield + st._SKILLS_CACHE.clear() + + +def _write_skill(root, category, name, description="a skill"): + d = root / "skills" / category / name + d.mkdir(parents=True, exist_ok=True) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\n# {name}\n", + encoding="utf-8", + ) + return d + + +def test_cache_hit_serves_copies_not_cache_objects(tmp_path): + """Callers mutate the returned dicts (web_server annotates + s['enabled']/s['usage']) — the cache must hand out per-call copies.""" + _write_skill(tmp_path, "cat-a", "skill-one") + first = st._find_all_skills() + assert [s["name"] for s in first] == ["skill-one"] + + # Mutate what the first caller got; the next (cached) call must be clean. + first[0]["enabled"] = False + first.append({"name": "junk"}) + + second = st._find_all_skills() + assert [s["name"] for s in second] == ["skill-one"] + assert "enabled" not in second[0], "cache poisoned by caller mutation" + assert second is not first + + +def test_nested_category_skill_add_invalidates(tmp_path): + """THE bug in the original PR: a new skill inside an existing category + bumps the category dir's mtime only — the root-mtime key missed it.""" + _write_skill(tmp_path, "cat-a", "skill-one") + first = st._find_all_skills() + assert [s["name"] for s in first] == ["skill-one"] + + # Freeze the ROOT dir's mtime so only the category-child signature moves + # (guards against filesystems bumping the parent too). + root = tmp_path / "skills" + root_stat = root.stat() + _write_skill(tmp_path, "cat-a", "skill-two") + import os + os.utime(root, (root_stat.st_atime, root_stat.st_mtime)) + + names = sorted(s["name"] for s in st._find_all_skills()) + assert names == ["skill-one", "skill-two"], ( + "category-nested skill add must invalidate the cache" + ) + + +def test_disabled_set_change_invalidates(tmp_path, monkeypatch): + """Disabling a skill is a config change with NO filesystem mtime bump — + it must still invalidate.""" + _write_skill(tmp_path, "cat-a", "skill-one") + _write_skill(tmp_path, "cat-a", "skill-two") + names = sorted(s["name"] for s in st._find_all_skills()) + assert names == ["skill-one", "skill-two"] + + monkeypatch.setattr(st, "_get_disabled_skill_names", lambda: {"skill-two"}) + names = sorted(s["name"] for s in st._find_all_skills()) + assert names == ["skill-one"], "disabled-set change must invalidate the cache" + + +def test_ttl_expiry_forces_rescan(tmp_path, monkeypatch): + """In-place SKILL.md edits are invisible to any directory signature; + the TTL bounds that staleness.""" + skill_dir = _write_skill(tmp_path, "cat-a", "skill-one", "old description") + first = st._find_all_skills() + assert first[0]["description"] == "old description" + + # Edit the file in place; keep every directory mtime identical. + import os + cat = tmp_path / "skills" / "cat-a" + root = tmp_path / "skills" + stats = {p: p.stat() for p in (root, cat, skill_dir)} + (skill_dir / "SKILL.md").write_text( + "---\nname: skill-one\ndescription: new description\n---\n# skill-one\n", + encoding="utf-8", + ) + for p, s in stats.items(): + os.utime(p, (s.st_atime, s.st_mtime)) + + # Within TTL: stale (documented trade-off). + assert st._find_all_skills()[0]["description"] == "old description" + + # Past TTL: fresh. + monkeypatch.setattr(st, "_SKILLS_CACHE_TTL_SECONDS", 0.0) + assert st._find_all_skills()[0]["description"] == "new description" + + +def test_disabled_and_full_views_cached_separately(tmp_path, monkeypatch): + _write_skill(tmp_path, "cat-a", "skill-one") + _write_skill(tmp_path, "cat-a", "skill-two") + monkeypatch.setattr(st, "_get_disabled_skill_names", lambda: {"skill-two"}) + + filtered = sorted(s["name"] for s in st._find_all_skills()) + everything = sorted(s["name"] for s in st._find_all_skills(skip_disabled=True)) + assert filtered == ["skill-one"] + assert everything == ["skill-one", "skill-two"] diff --git a/tests/tools/test_telegram_send_message_caption.py b/tests/tools/test_telegram_send_message_caption.py new file mode 100644 index 00000000000..aa21331c5f9 --- /dev/null +++ b/tests/tools/test_telegram_send_message_caption.py @@ -0,0 +1,141 @@ +"""Standalone Telegram MEDIA: caption delivery. + +When `hermes send --to telegram "MEDIA:/x.png This Caption"` carries a single +captionable file plus short text, the text must ride on the media bubble as the +sendPhoto/sendVideo/sendDocument ``caption`` rather than being posted as a +separate sendMessage beforehand. Longer text (> Telegram's 1024 caption cap) +falls back to a separate message. The ``telegram`` package is stubbed. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def _install_telegram_mock(monkeypatch: pytest.MonkeyPatch, bot_factory: MagicMock) -> None: + parse_mode = SimpleNamespace(MARKDOWN_V2="MarkdownV2", HTML="HTML") + constants_mod = SimpleNamespace(ParseMode=parse_mode) + _MessageEntity = lambda **_kw: SimpleNamespace(**_kw) + telegram_mod = SimpleNamespace( + Bot=bot_factory, + MessageEntity=_MessageEntity, + constants=constants_mod, + ) + monkeypatch.setitem(sys.modules, "telegram", telegram_mod) + monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod) + + +def _make_bot() -> MagicMock: + bot = MagicMock() + bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1)) + bot.send_photo = AsyncMock(return_value=SimpleNamespace(message_id=2)) + bot.send_video = AsyncMock(return_value=SimpleNamespace(message_id=3)) + bot.send_document = AsyncMock(return_value=SimpleNamespace(message_id=4)) + return bot + + +def _no_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + for var in ( + "TELEGRAM_PROXY", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", + "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.setattr("gateway.run._gateway_runner_ref", lambda: None, raising=False) + monkeypatch.setattr(sys, "platform", "linux") + + +def _tmpfile(suffix: str) -> str: + f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) + f.write(b"x") + f.close() + return f.name + + +def test_image_caption_rides_bubble_no_separate_text(monkeypatch: pytest.MonkeyPatch) -> None: + from tools.send_message_tool import _send_telegram + + _no_proxy(monkeypatch) + bot = _make_bot() + _install_telegram_mock(monkeypatch, MagicMock(return_value=bot)) + img = _tmpfile(".png") + try: + res = asyncio.run( + _send_telegram("tok", "123", "This Caption", media_files=[(img, False)]) + ) + assert res["success"] is True + # No separate text message; caption rides the photo. + bot.send_message.assert_not_awaited() + bot.send_photo.assert_awaited_once() + assert bot.send_photo.await_args.kwargs.get("caption") == "This Caption" + finally: + os.unlink(img) + + +def test_video_caption_rides_bubble(monkeypatch: pytest.MonkeyPatch) -> None: + from tools.send_message_tool import _send_telegram + + _no_proxy(monkeypatch) + bot = _make_bot() + _install_telegram_mock(monkeypatch, MagicMock(return_value=bot)) + vid = _tmpfile(".mp4") + try: + res = asyncio.run( + _send_telegram("tok", "123", "Model unit tour", media_files=[(vid, False)]) + ) + assert res["success"] is True + bot.send_message.assert_not_awaited() + bot.send_video.assert_awaited_once() + assert bot.send_video.await_args.kwargs.get("caption") == "Model unit tour" + finally: + os.unlink(vid) + + +def test_long_text_falls_back_to_separate_message(monkeypatch: pytest.MonkeyPatch) -> None: + from tools.send_message_tool import _send_telegram + + _no_proxy(monkeypatch) + bot = _make_bot() + _install_telegram_mock(monkeypatch, MagicMock(return_value=bot)) + img = _tmpfile(".png") + long_text = "x" * 1100 # over Telegram's 1024 caption cap + try: + res = asyncio.run( + _send_telegram("tok", "123", long_text, media_files=[(img, False)]) + ) + assert res["success"] is True + # Text too long for a caption — sent as its own message, photo uncaptioned. + bot.send_message.assert_awaited() + bot.send_photo.assert_awaited_once() + assert not bot.send_photo.await_args.kwargs.get("caption") + finally: + os.unlink(img) + + +def test_multi_file_keeps_separate_text(monkeypatch: pytest.MonkeyPatch) -> None: + from tools.send_message_tool import _send_telegram + + _no_proxy(monkeypatch) + bot = _make_bot() + _install_telegram_mock(monkeypatch, MagicMock(return_value=bot)) + img = _tmpfile(".png") + img2 = _tmpfile(".jpg") + try: + res = asyncio.run( + _send_telegram("tok", "123", "two pics", media_files=[(img, False), (img2, False)]) + ) + assert res["success"] is True + # Ambiguous caption→file association: text stays a separate message. + bot.send_message.assert_awaited() + assert bot.send_photo.await_count == 2 + for call in bot.send_photo.await_args_list: + assert not call.kwargs.get("caption") + finally: + os.unlink(img) + os.unlink(img2) diff --git a/tests/tools/test_transcription_deepinfra.py b/tests/tools/test_transcription_deepinfra.py new file mode 100644 index 00000000000..39952147dad --- /dev/null +++ b/tests/tools/test_transcription_deepinfra.py @@ -0,0 +1,66 @@ +"""Tests for the DeepInfra STT provider. + +``_transcribe_deepinfra`` is a thin shim that resolves credentials/model +then delegates to ``_transcribe_openai``. These two tests pin the +STT-specific gating (so an unset DEEPINFRA_API_KEY refuses dispatch) and +the delegation happy path; shared catalog/tag-filter behavior is covered +in ``tests/hermes_cli/test_api_key_providers.py``. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _isolation(monkeypatch): + import hermes_cli.models as _models_mod + monkeypatch.setattr(_models_mod, "_deepinfra_catalog_cache", {}) + yield + + +def test_get_provider_gating_keys_on_deepinfra_api_key(monkeypatch): + """Explicit-provider gate: DEEPINFRA_API_KEY presence flips ``deepinfra`` on/off.""" + monkeypatch.delenv("DEEPINFRA_API_KEY", raising=False) + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "deepinfra"}) == "none" + monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key") + assert _get_provider({"provider": "deepinfra"}) == "deepinfra" + + +def test_delegates_to_openai_handler_with_deepinfra_creds(monkeypatch, tmp_path): + """Happy path: pinned model → openai SDK invoked with DeepInfra base_url + key, + and the response carries ``provider="deepinfra"`` (not openai).""" + monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key") + audio = tmp_path / "speech.wav" + audio.write_bytes(b"\x00" * 16) + + captured: dict = {} + + class _FakeClient: + def __init__(self, api_key=None, base_url=None, timeout=None, max_retries=None): + captured["api_key"] = api_key + captured["base_url"] = base_url + transcriptions = MagicMock() + transcriptions.create = MagicMock(return_value=MagicMock(text="ok")) + self.audio = MagicMock(transcriptions=transcriptions) + def close(self): + pass + + fake_openai = MagicMock() + fake_openai.OpenAI = _FakeClient + fake_openai.APIError = Exception + fake_openai.APIConnectionError = ConnectionError + fake_openai.APITimeoutError = TimeoutError + + with patch.dict("sys.modules", {"openai": fake_openai}), \ + patch("tools.transcription_tools._load_stt_config", return_value={}): + from tools.transcription_tools import _transcribe_deepinfra + result = _transcribe_deepinfra(str(audio), "vendor/test-stt") + + assert result["success"] is True + assert result["provider"] == "deepinfra" + assert "deepinfra" in captured["base_url"] + assert captured["api_key"] == "test-key" diff --git a/tests/tools/test_tts_command_providers.py b/tests/tools/test_tts_command_providers.py index e3242274a00..616a88d7b88 100644 --- a/tests/tools/test_tts_command_providers.py +++ b/tests/tools/test_tts_command_providers.py @@ -493,6 +493,9 @@ class TestTextToSpeechToolWithCommandProvider: class TestCheckTtsRequirements: def test_configured_command_provider_satisfies_requirement(self): - cfg = {"providers": {"x": {"type": "command", "command": "echo x"}}} + cfg = { + "provider": "x", + "providers": {"x": {"type": "command", "command": "echo x"}}, + } with patch("tools.tts_tool._load_tts_config", return_value=cfg): assert check_tts_requirements() is True diff --git a/tests/tools/test_tts_deepinfra.py b/tests/tools/test_tts_deepinfra.py new file mode 100644 index 00000000000..7f0b7ad43d5 --- /dev/null +++ b/tests/tools/test_tts_deepinfra.py @@ -0,0 +1,82 @@ +"""Tests for the DeepInfra TTS provider. + +``_generate_deepinfra_tts`` is a thin shim that resolves credentials/model +then delegates to ``_generate_openai_tts``. These two tests pin the +delegation happy path and the no-hardcoded-fallback contract; shared +infrastructure (catalog fetch + tag filter) is covered in +``tests/hermes_cli/test_api_key_providers.py``. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _isolation(monkeypatch): + import hermes_cli.models as _models_mod + monkeypatch.setattr(_models_mod, "_deepinfra_catalog_cache", {}) + monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key") + yield + + +def test_raises_when_no_model_resolvable(monkeypatch, tmp_path): + """No-fallback contract: empty config + unreachable catalog → ValueError.""" + import urllib.request + monkeypatch.setattr( + urllib.request, "urlopen", + lambda *a, **kw: (_ for _ in ()).throw(Exception("offline")), + ) + from tools.tts_tool import _generate_deepinfra_tts + with pytest.raises(ValueError, match="No DeepInfra TTS model available"): + _generate_deepinfra_tts("hi", str(tmp_path / "out.mp3"), {}) + + +def test_delegates_to_openai_handler_with_deepinfra_creds(monkeypatch, tmp_path): + """Happy path: pinned model → openai SDK invoked with DeepInfra base_url + key.""" + captured: dict = {} + + class _FakeClient: + def __init__(self, api_key=None, base_url=None): + captured["api_key"] = api_key + captured["base_url"] = base_url + speech = MagicMock() + speech.create = MagicMock(return_value=MagicMock(stream_to_file=lambda p: None)) + self.audio = MagicMock(speech=speech) + def close(self): + pass + + with patch("tools.tts_tool._import_openai_client", return_value=_FakeClient): + from tools.tts_tool import _generate_deepinfra_tts + _generate_deepinfra_tts( + "hello", str(tmp_path / "out.mp3"), + {"deepinfra": {"model": "vendor/test-tts"}}, + ) + + assert "deepinfra" in captured["base_url"] + assert captured["api_key"] == "test-key" + + +def test_requirements_follow_explicit_deepinfra_provider(monkeypatch): + from tools import tts_tool + + monkeypatch.setattr( + tts_tool, + "_load_tts_config", + lambda: {"provider": "deepinfra", "deepinfra": {}}, + ) + monkeypatch.setattr(tts_tool, "_import_openai_client", lambda: object) + + assert tts_tool.check_tts_requirements() is True + + +def test_unselected_cloud_credentials_do_not_expose_edge_tool(monkeypatch): + from tools import tts_tool + + monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: {}) + monkeypatch.setattr(tts_tool, "_import_edge_tts", MagicMock(side_effect=ImportError)) + monkeypatch.setenv("OPENAI_API_KEY", "unselected-key") + + assert tts_tool.check_tts_requirements() is False diff --git a/tests/tools/test_tts_dotenv_fallback.py b/tests/tools/test_tts_dotenv_fallback.py index 0a4ea5a8ac2..979890486f4 100644 --- a/tests/tools/test_tts_dotenv_fallback.py +++ b/tests/tools/test_tts_dotenv_fallback.py @@ -269,6 +269,8 @@ class TestRegressionGuard: with patch( "hermes_cli.config.load_env", return_value={"MINIMAX_API_KEY": "dotenv-secret"}, + ), patch.object( + tts_tool, "_load_tts_config", return_value={"provider": "minimax"} ), patch.object(tts_tool, "_import_edge_tts", side_effect=ImportError), \ patch.object(tts_tool, "_import_elevenlabs", side_effect=ImportError), \ patch.object(tts_tool, "_import_openai_client", side_effect=ImportError), \ diff --git a/tests/tools/test_tts_gemini.py b/tests/tools/test_tts_gemini.py index 85254649d53..15e26bdbb12 100644 --- a/tests/tools/test_tts_gemini.py +++ b/tests/tools/test_tts_gemini.py @@ -447,5 +447,8 @@ class TestGeminiInCheckRequirements: raise ImportError("simulated") return real_import(name, *args, **kwargs) - with patch("builtins.__import__", side_effect=fake_import): + with patch( + "tools.tts_tool._load_tts_config", + return_value={"provider": "gemini"}, + ), patch("builtins.__import__", side_effect=fake_import): assert check_tts_requirements() is True diff --git a/tests/tools/test_tts_mistral.py b/tests/tools/test_tts_mistral.py index 6e98946b6c0..03735ff85f3 100644 --- a/tests/tools/test_tts_mistral.py +++ b/tests/tools/test_tts_mistral.py @@ -204,7 +204,10 @@ class TestCheckTtsRequirementsMistral: from tools.tts_tool import check_tts_requirements monkeypatch.setenv("MISTRAL_API_KEY", "test-key") - with patch("tools.tts_tool._import_edge_tts", side_effect=ImportError), \ + with patch( + "tools.tts_tool._load_tts_config", + return_value={"provider": "mistral"}, + ), patch("tools.tts_tool._import_edge_tts", side_effect=ImportError), \ patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError), \ patch("tools.tts_tool._import_openai_client", side_effect=ImportError), \ patch("tools.tts_tool._check_neutts_available", return_value=False): diff --git a/tests/tools/test_tts_piper.py b/tests/tools/test_tts_piper.py index 78567adf9bb..9de07d70c40 100644 --- a/tests/tools/test_tts_piper.py +++ b/tests/tools/test_tts_piper.py @@ -376,6 +376,7 @@ class TestTextToSpeechToolWithPiper: class TestCheckTtsRequirementsPiper: def test_piper_install_satisfies_requirements(self, monkeypatch): # Drop every other provider so we can isolate the piper signal. + monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: {"provider": "piper"}) monkeypatch.setattr(tts_tool, "_import_edge_tts", lambda: (_ for _ in ()).throw(ImportError())) monkeypatch.setattr(tts_tool, "_import_elevenlabs", lambda: (_ for _ in ()).throw(ImportError())) monkeypatch.setattr(tts_tool, "_import_openai_client", lambda: (_ for _ in ()).throw(ImportError())) diff --git a/tests/tools/test_video_generation_dynamic_schema.py b/tests/tools/test_video_generation_dynamic_schema.py index a9565dab3e9..e3049d54dfa 100644 --- a/tests/tools/test_video_generation_dynamic_schema.py +++ b/tests/tools/test_video_generation_dynamic_schema.py @@ -88,7 +88,10 @@ class TestDynamicSchemaBuilder: from tools.video_generation_tool import _build_dynamic_video_schema desc = _build_dynamic_video_schema()["description"] - assert "No video backend is configured" in desc + # No provider configured AND none available → description says so. The + # wording reflects the *resolved* active provider (mirrors execution), + # so it reads "available" rather than "configured". + assert "No video backend is available" in desc assert "hermes tools" in desc def test_generic_description_keeps_edit_extend_out_of_surface(self, cfg_home): diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index 535f930e080..aac919b7a32 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -548,6 +548,14 @@ class TestCheckWebApiKey: self._managed_patchers = [ patch("tools.web_tools.managed_nous_tools_enabled", return_value=True), patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True), + # ddgs availability is package-presence driven and the plugin + # registry can hold an available ddgs provider. Neutralize both + # fallback surfaces so this class only exercises env-key/gateway + # resolution — otherwise these tests flip on machines where the + # optional ``ddgs`` package is installed (dev venvs) vs CI. + patch("tools.web_tools._ddgs_package_importable", return_value=False), + patch("agent.web_search_registry.get_active_search_provider", return_value=None), + patch("agent.web_search_registry.get_active_extract_provider", return_value=None), ] for p in self._managed_patchers: p.start() @@ -568,6 +576,22 @@ class TestCheckWebApiKey: from tools.web_tools import check_web_api_key assert check_web_api_key() is True + def test_null_backend_value_does_not_crash(self): + # config.yaml with ``web:\n backend:`` yields backend=None. The gate + # must not raise AttributeError on None.lower() — mirrors _get_backend. + with patch("tools.web_tools._load_web_config", return_value={"backend": None}): + from tools.web_tools import check_web_api_key + assert check_web_api_key() is False + + def test_null_web_section_does_not_crash(self): + # config.yaml with a present-but-null ``web:`` section makes the raw + # ``.get("web", {})`` return None; _load_web_config must still yield a + # dict so no caller does None.get(...). + with patch("hermes_cli.config.load_config", return_value={"web": None}): + from tools.web_tools import _load_web_config, check_web_api_key + assert _load_web_config() == {} + assert check_web_api_key() is False + def test_firecrawl_key_only(self): with patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}): from tools.web_tools import check_web_api_key diff --git a/tests/tools/test_web_tools_dict_urls.py b/tests/tools/test_web_tools_dict_urls.py new file mode 100644 index 00000000000..d58f0695442 --- /dev/null +++ b/tests/tools/test_web_tools_dict_urls.py @@ -0,0 +1,116 @@ +"""Regression tests for model-forwarded web-search result objects.""" + +import json + +import pytest + +from agent import web_search_registry +from agent.web_search_provider import WebSearchProvider +from tools import web_tools + + +class _FakeExtractProvider(WebSearchProvider): + def __init__(self) -> None: + self.received_urls: list[str] = [] + + @property + def name(self) -> str: + return "dict-url-test" + + @property + def display_name(self) -> str: + return "Dict URL Test" + + def is_available(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + async def extract(self, urls, **kwargs): + self.received_urls.extend(urls) + return [ + {"url": url, "title": "", "content": "ok"} + for url in urls + ] + + +@pytest.fixture +def extract_provider(monkeypatch): + with web_search_registry._lock: + previous = dict(web_search_registry._providers) + web_search_registry._providers.clear() + + provider = _FakeExtractProvider() + web_search_registry.register_provider(provider) + monkeypatch.setattr(web_tools, "_ensure_web_plugins_loaded", lambda: None) + monkeypatch.setattr( + web_tools, + "_load_web_config", + lambda: {"extract_backend": provider.name}, + ) + + async def _safe(_url): + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _safe) + yield provider + + with web_search_registry._lock: + web_search_registry._providers.clear() + web_search_registry._providers.update(previous) + + +@pytest.mark.asyncio +async def test_web_extract_dispatches_urls_from_search_result_objects(extract_provider): + result = json.loads(await web_tools.web_extract_tool([ + {"url": "https://example.com/a", "title": "A"}, + {"href": "https://example.org/b"}, + ])) + + assert extract_provider.received_urls == [ + "https://example.com/a", + "https://example.org/b", + ] + assert [entry["url"] for entry in result["results"]] == extract_provider.received_urls + + +@pytest.mark.asyncio +async def test_web_extract_reports_invalid_items_without_dispatching_them(extract_provider): + result = json.loads(await web_tools.web_extract_tool([ + {"url": "https://example.com/good"}, + {"title": "missing URL"}, + {"url": 123}, + None, + ])) + + assert extract_provider.received_urls == ["https://example.com/good"] + assert [entry["url"] for entry in result["results"]] == [ + "https://example.com/good", + "", + "", + "", + ] + errors = [entry["error"] for entry in result["results"] if entry["error"]] + assert errors == [ + "Invalid URL item at index 1: expected a URL string or an object " + "with a string 'url' or 'href' field", + "Invalid URL item at index 2: expected a URL string or an object " + "with a string 'url' or 'href' field", + "Invalid URL item at index 3: expected a URL string or an object " + "with a string 'url' or 'href' field", + ] + + +def test_web_extract_registry_dispatch_accepts_search_result_objects( + extract_provider, +): + """The model-facing registry path preserves object URLs through dispatch.""" + raw = web_tools.registry.dispatch("web_extract", { + "urls": [{"url": "https://example.net/from-registry", "title": "R"}], + }) + assert isinstance(raw, str) + result = json.loads(raw) + + assert extract_provider.received_urls == ["https://example.net/from-registry"] + assert result["results"][0]["url"] == "https://example.net/from-registry" diff --git a/tests/tools/test_whatsapp_send_message_media.py b/tests/tools/test_whatsapp_send_message_media.py index d1fac4495e0..eef890edf98 100644 --- a/tests/tools/test_whatsapp_send_message_media.py +++ b/tests/tools/test_whatsapp_send_message_media.py @@ -219,3 +219,78 @@ def test_text_only_unchanged_behavior(): "message_id": "t1", } assert len(calls) == 1 and calls[0][0].endswith("/send") + + +def test_caption_rides_media_no_separate_text_send(): + """MEDIA: caption -> single /send-media with caption, no /send.""" + img = _tmpfile(".png") + try: + session_ctx, calls = _session_with([_resp(200, {"messageId": "m1"})]) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + "12345", + "", + media_files=[(img, False)], + caption="2-bedroom floor plan", + ) + ) + assert res["success"] is True + # No separate /send — exactly one /send-media carrying the caption. + assert len(calls) == 1 + assert calls[0][0].endswith("/send-media") + assert calls[0][1]["caption"] == "2-bedroom floor plan" + assert calls[0][1]["mediaType"] == "image" + finally: + os.unlink(img) + + +def test_caption_ignored_for_multi_file_send(): + """A caption never rides a multi-file send (association is ambiguous).""" + img = _tmpfile(".png") + img2 = _tmpfile(".jpg") + try: + session_ctx, calls = _session_with( + [_resp(200, {"messageId": "m1"}), _resp(200, {"messageId": "m2"})] + ) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + "12345", + "", + media_files=[(img, False), (img2, False)], + caption="should be ignored", + ) + ) + assert res["success"] is True + media_calls = [c for c in calls if c[0].endswith("/send-media")] + assert len(media_calls) == 2 + assert all("caption" not in c[1] for c in media_calls) + finally: + os.unlink(img) + os.unlink(img2) + + +def test_missing_captioned_file_falls_back_to_text(): + """If the single captioned file is missing, the caption is delivered as a + plain /send message rather than being silently lost (W1).""" + session_ctx, calls = _session_with([_resp(200, {"messageId": "t1"})]) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + "12345", + "", + media_files=[("/no/such/file.png", False)], + caption="floor plan", + ) + ) + # The send still surfaces the missing-file error... + assert "error" in res + assert "not found" in res["error"] + # ...but the caption text was delivered on its own first. + assert len(calls) == 1 + assert calls[0][0].endswith("/send") + assert calls[0][1]["message"] == "floor plan" diff --git a/tests/tui_gateway/test_finalize_session_persist.py b/tests/tui_gateway/test_finalize_session_persist.py index e1fe7ea5372..c927488de57 100644 --- a/tests/tui_gateway/test_finalize_session_persist.py +++ b/tests/tui_gateway/test_finalize_session_persist.py @@ -54,11 +54,11 @@ def _make_session(agent=None, history=None, session_key="test_key_001"): class TestFinalizeSessionPersist: """Verify _finalize_session flushes messages via _persist_session.""" - def test_persist_called_with_history(self): - """History from session is passed to agent._persist_session. - - When _session_messages is None (not yet set by any turn), - the session["history"] is used as the snapshot. + def test_no_session_messages_skips_persist(self): + """When _session_messages is empty/None the agent processed nothing + this session, so there is nothing new to flush. Falling back to + session["history"] here re-appended already-durable resumed rows as + duplicates, so finalize must NOT write in that case. """ from tui_gateway.server import _finalize_session @@ -66,20 +66,16 @@ class TestFinalizeSessionPersist: {"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi there"}, ] - agent = _make_agent() + agent = _make_agent() # _session_messages is None session = _make_session(agent=agent, history=history) _finalize_session(session, end_reason="test") - agent._persist_session.assert_called_once() - # snapshot = history (since _session_messages is None) - called_with = agent._persist_session.call_args[0][0] - assert called_with == history - # conversation_history kwarg passed for correct flush indexing - assert agent._persist_session.call_args[1].get("conversation_history") == history + agent._persist_session.assert_not_called() - def test_persist_uses_session_messages_when_available(self): - """agent._session_messages takes priority over session['history'].""" + def test_persist_uses_session_messages(self): + """agent._session_messages is flushed via the marker-based dedup path + (no conversation_history — passing the same list neutered the write).""" from tui_gateway.server import _finalize_session history = [{"role": "user", "content": "old"}] @@ -93,10 +89,10 @@ class TestFinalizeSessionPersist: _finalize_session(session) - agent._persist_session.assert_called_once() - called_with = agent._persist_session.call_args[0][0] - assert called_with == session_msgs # _session_messages wins - assert agent._persist_session.call_args[1].get("conversation_history") == history + agent._persist_session.assert_called_once_with(session_msgs) + # conversation_history must NOT be passed — it aliases the snapshot and + # makes _flush_messages_to_session_db skip every message. + assert "conversation_history" not in agent._persist_session.call_args[1] def test_commit_memory_still_called(self): """Existing memory commit path is preserved.""" @@ -158,6 +154,7 @@ class TestFinalizeSessionPersist: from tui_gateway.server import _finalize_session agent = _make_agent() + agent._session_messages = [{"role": "user", "content": "x"}] agent._persist_session.side_effect = RuntimeError("db is down") session = _make_session( agent=agent, @@ -165,6 +162,7 @@ class TestFinalizeSessionPersist: ) _finalize_session(session) # must not raise + agent._persist_session.assert_called_once() # commit_memory_session should still be called agent.commit_memory_session.assert_called_once() @@ -184,6 +182,154 @@ class TestFinalizeSessionPersist: mock_db.end_session.assert_called_once_with("sess_123", "test") +class TestFinalizeSessionPersistE2E: + """End-to-end: _finalize_session must actually land unflushed turns in + state.db on disconnect/restart. + + The mock-based tests above assert that _persist_session is *called*, but a + call whose arguments neuter the underlying flush persists nothing. These + tests drive the REAL AIAgent flush against a REAL SessionDB, reproducing + the "conversation contains many events yet is absent from state.db across + disconnect/restart" symptom. + """ + + @staticmethod + def _real_agent(db, session_id, session_messages): + from run_agent import AIAgent + + agent = object.__new__(AIAgent) + agent._session_db = db + agent._session_db_created = True + agent.session_id = session_id + agent.platform = "tui" + agent.model = "test-model" + agent._session_messages = session_messages + agent._last_flushed_db_idx = 0 + agent._flushed_db_message_ids = set() + agent._flushed_db_message_session_id = None + agent._persist_disabled = False + agent._cached_system_prompt = None + agent._session_init_model_config = None + agent._parent_session_id = None + agent._session_json_enabled = False + agent.quiet_mode = True + # commit_memory_session runs heavy machinery we don't exercise here. + agent.commit_memory_session = lambda *a, **k: None + return agent + + def test_unflushed_turn_survives_disconnect(self, tmp_path, monkeypatch): + """A completed turn whose transcript flush did NOT durably persist + (messages live only in agent._session_messages / session['history'], + never written to the DB) must be flushed to state.db when the WS + disconnect tears the session down.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + from hermes_state import SessionDB + import tui_gateway.server as srv + + db = SessionDB(db_path=tmp_path / "state.db") + session_id = "sess-unflushed" + db.create_session(session_id=session_id, source="tui") + monkeypatch.setattr(srv, "_get_db", lambda: db) + + # The live turn list that became session["history"] AND + # agent._session_messages (same object), but was never persisted. + turn = [ + {"role": "user", "content": "scan the repo and summarise"}, + {"role": "assistant", "content": "Here is the summary…"}, + {"role": "user", "content": "now open a PR"}, + {"role": "assistant", "content": "PR opened."}, + ] + agent = self._real_agent(db, session_id, turn) + session = _make_session(agent=agent, history=turn, session_key=session_id) + + assert db.get_messages_as_conversation(session_id) == [] + + srv._finalize_session(session, end_reason="ws_disconnect") + + after = db.get_messages_as_conversation(session_id) + contents = [m.get("content") for m in after] + assert len(after) == 4, after + assert any("scan the repo" in (c or "") for c in contents), contents + assert any("PR opened" in (c or "") for c in contents), contents + + def test_resumed_session_not_reflushed_as_duplicates(self, tmp_path, monkeypatch): + """A resumed session torn down before any new turn (its transcript is + already durable in the DB) must NOT re-append duplicate rows.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + from hermes_state import SessionDB + import tui_gateway.server as srv + + db = SessionDB(db_path=tmp_path / "state.db") + session_id = "sess-resumed" + db.create_session(session_id=session_id, source="tui") + loaded = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi there"}, + ] + for m in loaded: + db.append_message(session_id=session_id, role=m["role"], content=m["content"]) + monkeypatch.setattr(srv, "_get_db", lambda: db) + + # Resumed session: history hydrated from the DB, no turn ran, so the + # agent processed nothing this session. + agent = self._real_agent(db, session_id, []) + session = _make_session(agent=agent, history=loaded, session_key=session_id) + + srv._finalize_session(session, end_reason="ws_disconnect") + + after = db.get_messages_as_conversation(session_id) + assert len(after) == 2, after + + def test_resumed_then_run_turn_not_duplicated(self, tmp_path, monkeypatch): + """A resumed session that RUNS a turn must not have its loaded (durable) + prefix re-appended by finalize. + + This exercises the exact path the ``conversation_history`` argument used + to guard: the in-turn flush stamps the loaded prefix with + ``_DB_PERSISTED_MARKER`` (recognising it as durable), so the marker-only + finalize flush skips it. Without that stamping — or if finalize wrote a + markerless copy — the durable prefix would double. The + ``_session_messages``-empty test above skips the flush entirely, so it + can't catch a duplicate-write regression; this one drives a real flush. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + from hermes_state import SessionDB + import tui_gateway.server as srv + + db = SessionDB(db_path=tmp_path / "state.db") + session_id = "sess-resume-run" + db.create_session(session_id=session_id, source="tui") + loaded = [ + {"role": "user", "content": "remember: my cat is Mochi"}, + {"role": "assistant", "content": "Noted — Mochi."}, + ] + for m in loaded: + db.append_message(session_id=session_id, role=m["role"], content=m["content"]) + monkeypatch.setattr(srv, "_get_db", lambda: db) + + # Live turn list = loaded prefix (same dicts, as run_conversation copies + # conversation_history) + the new turn. + new_turn = [ + {"role": "user", "content": "what's the cat's name?"}, + {"role": "assistant", "content": "Mochi."}, + ] + messages = list(loaded) + new_turn + agent = self._real_agent(db, session_id, messages) + + # Drive the in-turn flush the way run_conversation does — the loaded + # prefix rides in as conversation_history, so it is recognised durable + # (and marker-stamped) while only the new turn is written. + agent._flush_messages_to_session_db(messages, conversation_history=loaded) + assert len(db.get_messages_as_conversation(session_id)) == 4 + + # WS disconnect → finalize. Must re-append nothing. + session = _make_session(agent=agent, history=messages, session_key=session_id) + srv._finalize_session(session, end_reason="ws_disconnect") + + after = db.get_messages_as_conversation(session_id) + assert len(after) == 4, after + + class TestOnSessionEndHook: """Verify on_session_end plugin hook fires on finalize.""" diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py index 0958a769688..cd0424c5d5a 100644 --- a/tests/tui_gateway/test_project_tree.py +++ b/tests/tui_gateway/test_project_tree.py @@ -180,6 +180,100 @@ def test_persisted_repo_root_used_when_no_live_probe(): assert _lane_ids(project) == ["/repo::branch::main"] +def test_non_git_cwd_preserves_legacy_workspace_grouping(): + # Before first-class Projects, every non-empty session cwd appeared as a + # workspace even when it was not a git repo. Historical sessions must keep + # that grouping instead of falling through to the flat Sessions list. + legacy = _session("/work/notes", title="Research notes") + + tree = pt.build_tree([], [legacy], [], resolve=lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["/work/notes"] + project = tree["projects"][0] + assert project["isAuto"] is True + assert project["label"] == "notes" + assert project["sessionCount"] == 1 + assert _lane_ids(project) == ["/work/notes"] + assert tree["scoped_session_ids"] == [legacy["id"]] + + +def test_non_git_windows_cwd_preserves_legacy_workspace_grouping(): + cwd = r"C:\Users\alice\workspace\notes" + legacy = _session(cwd) + + tree = pt.build_tree([], [legacy], [], resolve=lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == [cwd] + assert tree["projects"][0]["label"] == "notes" + assert tree["scoped_session_ids"] == [legacy["id"]] + + +def test_equivalent_windows_cwds_collapse_into_one_auto_project(): + sessions = [ + _session("C:/work/notes"), + _session(r"c:\WORK\notes"), + _session("C:/work/notes/"), + ] + + tree = pt.build_tree([], sessions, [], resolve=lambda _cwd: None, hydrate=True) + + assert len(tree["projects"]) == 1 + project = tree["projects"][0] + assert project["id"] == "C:/work/notes" + assert project["sessionCount"] == 3 + assert len(project["repos"]) == 1 + assert len(project["repos"][0]["groups"]) == 1 + assert len(project["repos"][0]["groups"][0]["sessions"]) == 3 + + +def test_windows_path_identity_preserves_explicit_project_priority(): + explicit = _project("p_notes", "Notes", ["C:/Work/Notes"]) + session = _session("c:\\work\\notes\\") + + tree = pt.build_tree([explicit], [session], [], resolve=lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["p_notes"] + assert tree["projects"][0]["sessionCount"] == 1 + assert tree["scoped_session_ids"] == [session["id"]] + + +def test_wsl_localhost_cwds_collapse_into_one_auto_project(): + # Root-relative WSL spellings (single leading backslash) are Windows paths, + # so case/separator variants collapse instead of spawning duplicate autos. + sessions = [ + _session(r"\wsl.localhost\Ubuntu\home\alice\proj"), + _session("//wsl.localhost/Ubuntu/home/alice/PROJ"), + ] + + tree = pt.build_tree([], sessions, [], resolve=lambda _cwd: None, hydrate=True) + + assert len(tree["projects"]) == 1 + assert tree["projects"][0]["sessionCount"] == 2 + + +def test_wsl_localhost_path_cannot_bypass_explicit_project(): + explicit = _project("p_proj", "Proj", [r"\wsl.localhost\Ubuntu\home\alice\proj"]) + session = _session("//wsl.localhost/Ubuntu/home/alice/PROJ/") + + tree = pt.build_tree([explicit], [session], [], resolve=lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["p_proj"] + assert tree["projects"][0]["sessionCount"] == 1 + assert tree["scoped_session_ids"] == [session["id"]] + + +def test_posix_path_identity_remains_case_sensitive(): + explicit = _project("p_notes", "Notes", ["/Work/Notes"]) + session = _session("/work/notes") + + tree = pt.build_tree([explicit], [session], [], resolve=lambda _cwd: None, hydrate=True) + + assert [(p["id"], p["sessionCount"]) for p in tree["projects"]] == [ + ("p_notes", 0), + ("/work/notes", 1), + ] + + def test_explicit_project_claims_sessions_and_beats_auto(): project = _project("p_app", "App", ["/www/app"]) resolve = _resolver( @@ -337,6 +431,41 @@ def test_junk_root_is_dropped_from_the_discovered_tier(): assert tree["projects"] == [] +def test_non_git_cwd_can_group_inside_a_junk_repo_subtree(): + # Repo discovery rejects the full state subtree, but a selected non-git + # descendant may be an intentional workspace carried over from the old UI. + workspace = _session("/home/test/.hermes/workspaces/notes") + + tree = pt.build_tree( + [], + [workspace], + [], + resolve=lambda _cwd: None, + hydrate=True, + is_junk_root=lambda path: path.startswith("/home/test/.hermes"), + is_junk_cwd=lambda path: path in {"/home/test", "/home/test/.hermes"}, + ) + + assert [p["id"] for p in tree["projects"]] == ["/home/test/.hermes/workspaces/notes"] + assert tree["scoped_session_ids"] == [workspace["id"]] + + +def test_broad_default_non_git_cwd_stays_unscoped(): + detached = _session("/home/test/.hermes") + + tree = pt.build_tree( + [], + [detached], + [], + resolve=lambda _cwd: None, + hydrate=True, + is_junk_cwd=lambda path: path in {"/home/test", "/home/test/.hermes"}, + ) + + assert tree["projects"] == [] + assert detached["id"] not in tree["scoped_session_ids"] + + def test_colliding_repo_basenames_disambiguate_labels(): resolve = _resolver( { diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 5aa3daa00ec..274ad8906be 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -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 @@ -1266,6 +1308,58 @@ def test_slash_exec_rejects_skill_commands(server): assert "skill command" in resp["error"]["message"] +def test_slash_exec_routes_custom_skill_bundle_away_from_worker(server): + """slash.exec expands any custom bundle through command.dispatch.""" + sid = "test-session" + + class Worker: + def __init__(self): + self.calls = [] + + def run(self, cmd): + self.calls.append(cmd) + return f"worker:{cmd}" + + worker = Worker() + server._sessions[sid] = { + "session_key": sid, + "agent": None, + "slash_worker": worker, + } + fake_bundles = { + "/analysis-pack": { + "name": "analysis-pack", + "skills": ["source-check", "claim-audit"], + } + } + fake_msg = ( + '[IMPORTANT: The user has invoked the "analysis-pack" skill bundle.]\n\n' + "User instruction: compare vector databases" + ) + + with patch("agent.skill_bundles.get_skill_bundles", return_value=fake_bundles), \ + patch( + "agent.skill_bundles.build_bundle_invocation_message", + return_value=(fake_msg, ["source-check", "claim-audit"], []), + ): + resp = server.handle_request({ + "id": "r-bundle-slash", + "method": "slash.exec", + "params": { + "command": "analysis-pack compare vector databases", + "session_id": sid, + }, + }) + + assert "error" not in resp + assert resp["result"] == { + "type": "send", + "message": fake_msg, + "notice": "⚡ Loading bundle: analysis-pack (2 skills)", + } + assert worker.calls == [] + + def test_slash_exec_handles_plugin_commands_in_live_gateway(server): """Plugin slash commands return normal slash.exec output without using the worker.""" sid = "test-session" @@ -1418,6 +1512,37 @@ def test_command_dispatch_queue_sends_message(server): assert result["message"] == "tell me about quantum computing" +def test_command_dispatch_builtin_queue_wins_over_colliding_bundle(server): + """A custom /queue bundle must not shadow the built-in /queue command.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid} + fake_bundles = { + "/queue": { + "name": "queue", + "skills": ["source-check", "claim-audit"], + } + } + + with patch("agent.skill_bundles.get_skill_bundles", return_value=fake_bundles), \ + patch("agent.skill_bundles.build_bundle_invocation_message") as build_bundle: + resp = server.handle_request({ + "id": "r-queue-collision", + "method": "command.dispatch", + "params": { + "name": "queue", + "arg": "tell me about quantum computing", + "session_id": sid, + }, + }) + + assert "error" not in resp + assert resp["result"] == { + "type": "send", + "message": "tell me about quantum computing", + } + build_bundle.assert_not_called() + + def test_command_dispatch_queue_requires_arg(server): """command.dispatch /queue without an argument returns an error.""" sid = "test-session" @@ -1619,6 +1744,54 @@ def test_command_dispatch_returns_skill_payload(server): assert result["name"] == "hermes-agent-dev" +def test_command_dispatch_returns_custom_bundle_payload(server): + """command.dispatch preserves bundle arguments in a sendable agent turn.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid} + fake_bundles = { + "/review-suite": { + "name": "review-suite", + "skills": ["source-check", "claim-audit", "enough-research"], + } + } + arg = "audit the migration plan" + fake_msg = ( + '[IMPORTANT: The user has invoked the "review-suite" skill bundle.]\n\n' + f"User instruction: {arg}" + ) + + with patch("agent.skill_bundles.get_skill_bundles", return_value=fake_bundles), \ + patch( + "agent.skill_bundles.build_bundle_invocation_message", + return_value=( + fake_msg, + ["source-check", "claim-audit", "enough-research"], + [], + ), + ) as build_bundle, \ + patch("agent.skill_commands.build_skill_invocation_message") as build_skill, \ + patch.object(server, "_resolve_session_platform", return_value="tui"): + resp = server.handle_request({ + "id": "r-bundle-dispatch", + "method": "command.dispatch", + "params": {"name": "review-suite", "arg": arg, "session_id": sid}, + }) + + assert "error" not in resp + assert resp["result"] == { + "type": "send", + "message": fake_msg, + "notice": "⚡ Loading bundle: review-suite (3 skills)", + } + build_bundle.assert_called_once_with( + "/review-suite", + arg, + task_id=sid, + platform="tui", + ) + build_skill.assert_not_called() + + def test_command_dispatch_awaits_async_plugin_handler(server): async def _handler(arg): return f"async:{arg}" diff --git a/tests/tui_gateway/test_session_platform_resolution.py b/tests/tui_gateway/test_session_platform_resolution.py index da241530604..dd2b6eda85c 100644 --- a/tests/tui_gateway/test_session_platform_resolution.py +++ b/tests/tui_gateway/test_session_platform_resolution.py @@ -17,14 +17,18 @@ The resolver helper is import-safe (no heavy module side effects) so it can be unit-tested without spinning up the full gateway. """ -import importlib - import pytest def _reload_resolver(): + # Plain import — every resolver under test reads the env at CALL time, so + # no reload is needed. importlib.reload(tui_gateway.server) would + # re-register the module's atexit hooks (thread-pool shutdown + + # _shutdown_sessions) on every test; duplicated hooks race the stderr + # buffer at interpreter shutdown (Fatal Python error: + # _enter_buffered_busy) — same flake class as PR #34217. Name kept for + # the existing call sites. import tui_gateway.server as _srv - importlib.reload(_srv) return _srv diff --git a/tests/tui_gateway/test_slash_worker_mcp_discovery.py b/tests/tui_gateway/test_slash_worker_mcp_discovery.py new file mode 100644 index 00000000000..82b59fe4b8e --- /dev/null +++ b/tests/tui_gateway/test_slash_worker_mcp_discovery.py @@ -0,0 +1,105 @@ +"""Integration coverage for profile-local MCP discovery in slash workers.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import queue +import subprocess +import sys +import textwrap +import threading + +import pytest +import yaml + +pytest.importorskip("mcp.server.fastmcp") + + +def test_profile_local_mcp_tool_is_visible_in_slash_worker(tmp_path): + profile_home = tmp_path / "profile-home" + profile_home.mkdir() + marker = "profile-local-61922" + server = tmp_path / "fastmcp_probe.py" + server.write_text( + textwrap.dedent( + f""" + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("profileprobe") + + @mcp.tool() + def hermes_61922_profile_probe() -> str: + return {marker!r} + + if __name__ == "__main__": + mcp.run(transport="stdio") + """ + ), + encoding="utf-8", + ) + (profile_home / "config.yaml").write_text( + yaml.safe_dump( + { + "mcp_servers": { + "profileprobe": { + "enabled": True, + "command": sys.executable, + "args": [str(server)], + } + } + } + ), + encoding="utf-8", + ) + + env = os.environ.copy() + for key in list(env): + if key.endswith("_API_KEY") or key.endswith("_TOKEN"): + env.pop(key) + env["HERMES_HOME"] = str(profile_home) + env["PYTHONPATH"] = str(Path(__file__).resolve().parents[2]) + env["HERMES_SLASH_WATCHDOG_GRACE_S"] = "0" + env["HERMES_SLASH_WATCHDOG_POLL_S"] = "0.05" + proc = subprocess.Popen( + [ + sys.executable, + "-u", + "-m", + "tui_gateway.slash_worker", + "--session-key", + "agent:main:tui:dm:mcp-profile-test", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + cwd=tmp_path, + ) + output: queue.Queue[str] = queue.Queue() + try: + assert proc.stdin is not None + assert proc.stdout is not None + stdout = proc.stdout + threading.Thread( + target=lambda: output.put(stdout.readline()), + daemon=True, + ).start() + proc.stdin.write(json.dumps({"id": 1, "command": "/tools"}) + "\n") + proc.stdin.flush() + try: + line = output.get(timeout=10) + except queue.Empty: + pytest.fail("slash worker produced no /tools response within 10 seconds") + response = json.loads(line) + assert response["ok"] is True + assert "mcp__profileprobe__hermes_61922_profile_probe" in response["output"] + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) diff --git a/tests/tui_gateway/test_undo_command.py b/tests/tui_gateway/test_undo_command.py index fd1dbca5905..cd0f7e5c4e0 100644 --- a/tests/tui_gateway/test_undo_command.py +++ b/tests/tui_gateway/test_undo_command.py @@ -43,11 +43,16 @@ def server(hermes_home): ): mod = importlib.import_module("tui_gateway.server") yield mod + # Reset module-level session state without re-importing. importlib.reload + # would re-register the module's atexit hooks; duplicated hooks race the + # stderr buffer at interpreter shutdown (Fatal Python error: + # _enter_buffered_busy) — same class as PR #34217. mod._sessions.clear() mod._pending.clear() mod._answers.clear() - mod._methods.clear() - importlib.reload(mod) + # NOTE: _methods is intentionally NOT cleared — it's populated at import + # time and would only repopulate via reload. + mod._db = None @pytest.fixture() diff --git a/tools/approval.py b/tools/approval.py index 05f2eb523cc..7c58062ed58 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -17,6 +17,7 @@ import os import re import shlex import sys +import tempfile import threading import time import unicodedata @@ -119,6 +120,53 @@ def _fire_approval_hook(hook_name: str, **kwargs) -> None: logger.debug("Approval hook %s dispatch failed: %s", hook_name, exc) +def _prepare_smart_approval_observer( + *, + command: str, + description: str, + pattern_key: str, + pattern_keys: list[str], + session_key: str, +) -> dict | None: + """Redact and emit the pre-decision smart approval observer hook. + + Redaction is part of observer payload preparation, not approval policy. If + it fails, skip all observability rather than leaking raw data or preventing + the auxiliary LLM from making its decision. + """ + try: + from agent.redact import redact_sensitive_text + + hook_command = redact_sensitive_text(command, force=True) + hook_description = redact_sensitive_text(description, force=True) + except Exception as exc: + logger.debug("Smart approval hook redaction failed: %s", exc) + return + + payload = { + "command": hook_command, + "description": hook_description, + "pattern_key": pattern_key, + "pattern_keys": list(pattern_keys), + "session_key": session_key, + "surface": "smart", + } + _fire_approval_hook("pre_approval_request", **payload) + return payload + + +def _observe_smart_approval_verdict(payload: dict | None, verdict: str) -> None: + """Emit a smart verdict after the auxiliary LLM decision, if safe.""" + if payload is None or verdict not in {"approve", "deny"}: + return + _fire_approval_hook( + "post_approval_response", + **payload, + choice=f"smart_{verdict}", + decided_by="aux_llm", + ) + + def set_current_session_key(session_key: str) -> contextvars.Token[str]: """Bind the active approval session key to the current context.""" @@ -1383,12 +1431,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: @@ -1663,16 +1735,21 @@ def save_permanent_allowlist(patterns: set): def prompt_dangerous_approval(command: str, description: str, timeout_seconds: int | None = None, allow_permanent: bool = True, - approval_callback=None) -> str: + approval_callback=None, + *, smart_denied: bool = False) -> str: """Prompt the user to approve a dangerous command (CLI only). Args: allow_permanent: When False, hide the [a]lways option (used when tirith warnings are present, since broad permanent allowlisting is inappropriate for content-level security findings). + smart_denied: When True, this is an owner override of a Smart DENY. + Offer only one-operation approval or denial. approval_callback: Optional callback registered by the CLI for prompt_toolkit integration. Signature: - (command, description, *, allow_permanent=True) -> str. + (command, description, *, allow_permanent=True, + smart_denied=False) -> str. Legacy callback signatures remain + supported when ``smart_denied`` is false. Returns: 'once', 'session', 'always', or 'deny' """ @@ -1689,8 +1766,12 @@ def prompt_dangerous_approval(command: str, description: str, if approval_callback is not None: try: - return approval_callback(display_command, display_description, - allow_permanent=allow_permanent) + callback_kwargs = {"allow_permanent": allow_permanent} + if smart_denied: + callback_kwargs["smart_denied"] = True + return approval_callback( + display_command, display_description, **callback_kwargs + ) except Exception as e: logger.error("Approval callback failed: %s", e, exc_info=True) return "deny" @@ -1732,7 +1813,9 @@ def prompt_dangerous_approval(command: str, description: str, print(f" {t('approval.dangerous_header', description=display_description)}") print(f" {display_command}") print() - if allow_permanent: + if smart_denied: + print(t("approval.choose_smart_deny")) + elif allow_permanent: print(t("approval.choose_long")) else: print(t("approval.choose_short")) @@ -1743,7 +1826,10 @@ def prompt_dangerous_approval(command: str, description: str, def get_input(): try: - prompt = t("approval.prompt_long") if allow_permanent else t("approval.prompt_short") + if smart_denied: + prompt = t("approval.prompt_smart_deny") + else: + prompt = t("approval.prompt_long") if allow_permanent else t("approval.prompt_short") result["choice"] = input(prompt).strip().lower() except (EOFError, OSError): result["choice"] = "" @@ -1757,6 +1843,21 @@ def prompt_dangerous_approval(command: str, description: str, return "deny" choice = result["choice"] + if smart_denied: + choice_map = { + **{ + value: "once" + for value in t("approval.smart_deny_once_inputs").split(",") + }, + **{ + value: "deny" + for value in t("approval.smart_deny_deny_inputs").split(",") + }, + } + decision = choice_map.get(choice, "deny") + print(t("approval.allowed_once" if decision == "once" else "approval.denied")) + return decision + if choice in {'o', 'once'}: print(t("approval.allowed_once")) return "once" @@ -2468,15 +2569,12 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, _drop_entry() return {"resolved": False, "choice": None, "notify_failed": True} - # Block until the user responds or timeout (default 5 min). Poll in short - # slices so we can fire activity heartbeats every ~10s to the agent's - # inactivity tracker — otherwise the gateway watchdog kills the agent - # while the user is still responding. Mirrors _wait_for_process() cadence. - timeout = _get_approval_config().get("gateway_timeout", 300) - try: - timeout = int(timeout) - except (ValueError, TypeError): - timeout = 300 + # Block until the user responds or the canonical approval timeout elapses + # (default 60s). Poll in short slices so we can fire activity heartbeats + # every ~10s to the agent's inactivity tracker — otherwise the gateway + # watchdog kills the agent while the user is still responding. Mirrors + # _wait_for_process() cadence. + timeout = _get_approval_timeout() try: from tools.environments.base import touch_activity_if_due @@ -2741,27 +2839,38 @@ def check_all_command_guards(command: str, env_type: str, # When approvals.mode=smart, ask the aux LLM before prompting the user. # Inspired by OpenAI Codex's Smart Approvals guardian subagent # (openai/codex#13860). + smart_denied_for_owner = False if approval_mode == "smart": combined_desc_for_llm = "; ".join(desc for _, desc, _ in warnings) + observer_payload = _prepare_smart_approval_observer( + command=command, + description=combined_desc_for_llm, + pattern_key=warnings[0][0], + pattern_keys=[key for key, _, _ in warnings], + session_key=session_key, + ) verdict = _smart_approve(command, combined_desc_for_llm) + _observe_smart_approval_verdict(observer_payload, verdict) if verdict == "approve": - # Auto-approve and grant session-level approval for these patterns - for key, _, _ in warnings: - approve_session(session_key, key) + # Approve this command only. Pattern-level persistence would let one + # benign command suppress review of later commands that happen to + # match the same broad detector category. logger.debug("Smart approval: auto-approved '%s' (%s)", command[:60], combined_desc_for_llm) return {"approved": True, "message": None, "smart_approved": True, "description": combined_desc_for_llm} - elif verdict == "deny": - combined_desc_for_llm = "; ".join(desc for _, desc, _ in warnings) + elif verdict == "deny" and not (is_cli or is_gateway or is_ask): return { "approved": False, "message": f"BLOCKED by smart approval: {combined_desc_for_llm}. " "The command was assessed as genuinely dangerous. Do NOT retry.", "smart_denied": True, } - # verdict == "escalate" → fall through to manual prompt + elif verdict == "deny": + smart_denied_for_owner = True + # An interactive owner may override DENY for this operation only. + # ESCALATE follows the normal, potentially persistent manual behavior. # --- Phase 3: Approval --- @@ -2798,10 +2907,12 @@ def check_all_command_guards(command: str, env_type: str, "pattern_key": primary_key, "pattern_keys": all_keys, "description": redact_sensitive_text(combined_desc), - # Mirror the CLI's allow_permanent gate: a tirith warning downgrades - # "always" to session scope below, so the UI must not offer it. - "allow_permanent": not has_tirith, + # Smart DENY overrides are one-operation decisions, so the UI + # must not offer a permanent scope. + "allow_permanent": not has_tirith and not smart_denied_for_owner, } + if smart_denied_for_owner: + approval_data["smart_denied"] = True decision = _await_gateway_decision( session_key, notify_cb, approval_data, surface="gateway" ) @@ -2854,16 +2965,17 @@ def check_all_command_guards(command: str, env_type: str, "deny_reason": deny_reason, } - # User approved — persist based on scope (same logic as CLI) - for key, _, is_tirith in warnings: - if choice == "session" or (choice == "always" and is_tirith): - approve_session(session_key, key) - elif choice == "always": - approve_session(session_key, key) - approve_permanent(key) - save_permanent_allowlist(_permanent_approved) - # choice == "once": no persistence — command allowed this - # single time only, matching the CLI's behavior. + # A smart-DENY owner override is always one operation, even if an + # older client returns "session" or "always". Manual and ESCALATE + # choices retain their existing persistence semantics. + if not smart_denied_for_owner: + for key, _, is_tirith in warnings: + if choice == "session" or (choice == "always" and is_tirith): + approve_session(session_key, key) + elif choice == "always": + approve_session(session_key, key) + approve_permanent(key) + save_permanent_allowlist(_permanent_approved) return {"approved": True, "message": None, "user_approved": True, "description": combined_desc} @@ -2875,13 +2987,16 @@ def check_all_command_guards(command: str, env_type: str, from agent.redact import redact_sensitive_text _disp_command = redact_sensitive_text(command) _disp_combined_desc = redact_sensitive_text(combined_desc) - submit_pending(session_key, { + pending_data = { "command": _disp_command, "pattern_key": primary_key, "pattern_keys": all_keys, "description": _disp_combined_desc, - }) - return { + } + if smart_denied_for_owner: + pending_data.update(smart_denied=True, allow_permanent=False) + submit_pending(session_key, pending_data) + result = { "approved": False, "pattern_key": primary_key, "status": "pending_approval", @@ -2892,6 +3007,9 @@ def check_all_command_guards(command: str, env_type: str, f"⚠️ {_disp_combined_desc}. Asking the user for approval.\n\n**Command:**\n```\n{_disp_command}\n```" ), } + if smart_denied_for_owner: + result.update(smart_denied=True, allow_permanent=False) + return result # CLI interactive: single combined prompt # Hide [a]lways when any tirith warning is present @@ -2904,9 +3022,13 @@ def check_all_command_guards(command: str, env_type: str, session_key=session_key, surface="cli", ) - choice = prompt_dangerous_approval(command, combined_desc, - allow_permanent=not has_tirith, - approval_callback=approval_callback) + choice = prompt_dangerous_approval( + command, + combined_desc, + allow_permanent=not has_tirith and not smart_denied_for_owner, + smart_denied=smart_denied_for_owner, + approval_callback=approval_callback, + ) _fire_approval_hook( "post_approval_response", command=command, @@ -2935,16 +3057,18 @@ def check_all_command_guards(command: str, env_type: str, "user_consent": False, } - # Persist approval for each warning individually - for key, _, is_tirith in warnings: - if choice == "session" or (choice == "always" and is_tirith): - # tirith: session only (no permanent broad allowlisting) - approve_session(session_key, key) - elif choice == "always": - # dangerous patterns: permanent allowed - approve_session(session_key, key) - approve_permanent(key) - save_permanent_allowlist(_permanent_approved) + # Smart-DENY owner overrides are one-operation scoped. Preserve existing + # persistence for manual mode and smart ESCALATE. + if not smart_denied_for_owner: + for key, _, is_tirith in warnings: + if choice == "session" or (choice == "always" and is_tirith): + # tirith: session only (no permanent broad allowlisting) + approve_session(session_key, key) + elif choice == "always": + # dangerous patterns: permanent allowed + approve_session(session_key, key) + approve_permanent(key) + save_permanent_allowlist(_permanent_approved) return {"approved": True, "message": None, "user_approved": True, "description": combined_desc} @@ -3026,17 +3150,6 @@ def check_execute_code_guard(code: str, env_type: str, # paths don't pay to copy a potentially-large script into this string. command = f"execute_code <<'PY'\n{code}\nPY" - # Redacted copies for user-visible rendering only. An execute_code script - # can embed credentials (e.g. api_key = "sk-..."), and the gateway renders - # this payload directly to Discord/Slack — those messages are - # screenshottable. The raw `command`/`code` are still what get assessed by - # smart approval and executed; redaction is display-only. Approval - # persistence keys off pattern_key, so the allowlist is unaffected. - from agent.redact import redact_sensitive_text - display_command = redact_sensitive_text(command) - display_code = redact_sensitive_text(code) - display_description = redact_sensitive_text(description) - # Check session/permanent approval — same gate as check_all_command_guards. # Without this, "Approve session" / "Always" choices are stored but never # consulted, so every execute_code call re-prompts the user (#39275). @@ -3046,14 +3159,23 @@ def check_execute_code_guard(code: str, env_type: str, # Smart mode: ask the aux LLM about the whole script. An APPROVE here only # suppresses the redundant whole-script prompt; the per-call terminal() # guards (restored by context propagation) still run independently. + smart_denied_for_owner = False if approval_mode == "smart": + observer_payload = _prepare_smart_approval_observer( + command=command, + description=description, + pattern_key=pattern_key, + pattern_keys=[pattern_key], + session_key=session_key, + ) verdict = _smart_approve(command, description) + _observe_smart_approval_verdict(observer_payload, verdict) if verdict == "approve": logger.debug("Smart approval: auto-approved execute_code for session %s", session_key) return {"approved": True, "message": None, "smart_approved": True, "description": description} - if verdict == "deny": + if verdict == "deny" and not (is_gateway or is_ask): return { "approved": False, "message": ("BLOCKED by smart approval: execute_code script " @@ -3065,7 +3187,21 @@ def check_execute_code_guard(code: str, env_type: str, "outcome": "denied", "user_consent": False, } - # verdict == "escalate" → fall through to manual approval + if verdict == "deny": + smart_denied_for_owner = True + # Interactive DENY falls through to one-operation human approval; + # ESCALATE retains the normal manual approval behavior. + + # Redacted copies for user-visible rendering only. An execute_code script + # can embed credentials (e.g. api_key = "sk-..."), and the gateway renders + # this payload directly to Discord/Slack — those messages are + # screenshottable. The raw `command`/`code` are still what get assessed by + # smart approval and executed; redaction is display-only. Approval + # persistence keys off pattern_key, so the allowlist is unaffected. + from agent.redact import redact_sensitive_text + display_command = redact_sensitive_text(command) + display_code = redact_sensitive_text(code) + display_description = redact_sensitive_text(description) notify_cb = None with _lock: @@ -3074,13 +3210,16 @@ def check_execute_code_guard(code: str, env_type: str, if notify_cb is None: # No gateway callback registered (e.g. ask-mode without a notifier): # surface a pending approval for backward compatibility. - submit_pending(session_key, { + pending_data = { "command": display_command, "pattern_key": pattern_key, "pattern_keys": [pattern_key], "description": display_description, - }) - return { + } + if smart_denied_for_owner: + pending_data.update(smart_denied=True, allow_permanent=False) + submit_pending(session_key, pending_data) + result = { "approved": False, "pattern_key": pattern_key, "status": "pending_approval", @@ -3092,13 +3231,19 @@ def check_execute_code_guard(code: str, env_type: str, f"**Code:**\n```python\n{display_code}\n```" ), } + if smart_denied_for_owner: + result.update(smart_denied=True, allow_permanent=False) + return result approval_data = { "command": display_command, "pattern_key": pattern_key, "pattern_keys": [pattern_key], "description": display_description, + "allow_permanent": not smart_denied_for_owner, } + if smart_denied_for_owner: + approval_data["smart_denied"] = True decision = _await_gateway_decision( session_key, notify_cb, approval_data, surface="gateway" ) @@ -3138,13 +3283,16 @@ def check_execute_code_guard(code: str, env_type: str, "deny_reason": deny_reason, } - # Approved — persist based on scope (same logic as check_all_command_guards). - if choice == "session": - approve_session(session_key, pattern_key) - elif choice == "always": - approve_session(session_key, pattern_key) - approve_permanent(pattern_key) - save_permanent_allowlist(_permanent_approved) + # Never persist a smart-DENY override under the coarse execute_code key; + # doing so would approve unrelated future scripts. Manual and ESCALATE + # decisions preserve their existing session/permanent behavior. + if not smart_denied_for_owner: + if choice == "session": + approve_session(session_key, pattern_key) + elif choice == "always": + approve_session(session_key, pattern_key) + approve_permanent(pattern_key) + save_permanent_allowlist(_permanent_approved) # choice == "once": no persistence — approval lasts this single call only. return {"approved": True, "message": None, diff --git a/tools/async_delegation.py b/tools/async_delegation.py index 9b4b0e9646a..d2f2dc23b91 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -36,13 +36,16 @@ logic stays in one place. from __future__ import annotations +import json import logging +import sqlite3 import threading import time import uuid from concurrent.futures import ThreadPoolExecutor from typing import Any, Callable, Dict, List, Optional +from hermes_constants import get_hermes_home from tools.daemon_pool import DaemonThreadPoolExecutor from tools.thread_context import propagate_context_to_thread @@ -72,6 +75,304 @@ _records: Dict[str, Dict[str, Any]] = {} _DEFAULT_MAX_ASYNC_CHILDREN = 3 # How many completed records to retain for status queries before pruning. _MAX_RETAINED_COMPLETED = 50 +_DURABLE_RETENTION_SECONDS = 7 * 24 * 60 * 60 +_MAX_DURABLE_PENDING = 1000 +_DB_LOCK = threading.Lock() + + +def _db_path(): + return get_hermes_home() / "state.db" + + +def _connect() -> sqlite3.Connection: + path = _db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path, timeout=10) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute( + """CREATE TABLE IF NOT EXISTS async_delegations ( + delegation_id TEXT PRIMARY KEY, + origin_session TEXT NOT NULL, + origin_ui_session_id TEXT NOT NULL DEFAULT '', + parent_session_id TEXT, + state TEXT NOT NULL, + dispatched_at REAL NOT NULL, + completed_at REAL, + updated_at REAL NOT NULL, + event_json TEXT, + result_json TEXT, + delivery_state TEXT NOT NULL DEFAULT 'pending', + delivery_attempts INTEGER NOT NULL DEFAULT 0, + delivered_at REAL, + owner_pid INTEGER, + owner_started_at INTEGER, + task_json TEXT, + delivery_claim TEXT, + delivery_claimed_at REAL + )""" + ) + columns = {row[1] for row in conn.execute("PRAGMA table_info(async_delegations)")} + for name, sql_type in ( + ("owner_pid", "INTEGER"), + ("owner_started_at", "INTEGER"), + ("task_json", "TEXT"), + ("delivery_claim", "TEXT"), + ("delivery_claimed_at", "REAL"), + ): + if name not in columns: + conn.execute(f"ALTER TABLE async_delegations ADD COLUMN {name} {sql_type}") + return conn + + +def _persist_dispatch(record: Dict[str, Any]) -> None: + now = time.time() + try: + from gateway.status import get_process_start_time + owner_started_at = get_process_start_time(__import__("os").getpid()) + except Exception: + owner_started_at = None + task_payload = { + key: record.get(key) + for key in ("goal", "goals", "context", "toolsets", "role", "model", "is_batch") + if key in record + } + with _DB_LOCK, _connect() as conn: + conn.execute( + """INSERT OR REPLACE INTO async_delegations + (delegation_id, origin_session, origin_ui_session_id, + parent_session_id, state, dispatched_at, updated_at, + delivery_state, delivery_attempts, owner_pid, + owner_started_at, task_json) + VALUES (?, ?, ?, ?, 'running', ?, ?, 'pending', 0, ?, ?, ?)""", + (record["delegation_id"], record.get("session_key", ""), + record.get("origin_ui_session_id", ""), record.get("parent_session_id"), + record["dispatched_at"], now, __import__("os").getpid(), + owner_started_at, json.dumps(task_payload)), + ) + _prune_durable_records() + + +def _delete_durable_delegation(delegation_id: str) -> None: + with _DB_LOCK, _connect() as conn: + conn.execute("DELETE FROM async_delegations WHERE delegation_id=?", (delegation_id,)) + + +def _prune_durable_records() -> None: + """Bound terminal history, preferring delivered records for deletion.""" + now = time.time() + cutoff = now - _DURABLE_RETENTION_SECONDS + with _DB_LOCK, _connect() as conn: + conn.execute( + "DELETE FROM async_delegations WHERE delivery_state='delivered' AND updated_at < ?", + (cutoff,), + ) + terminal_count = conn.execute( + "SELECT COUNT(*) FROM async_delegations WHERE state NOT IN ('running','finalizing')" + ).fetchone()[0] + excess = max(0, terminal_count - _MAX_RETAINED_COMPLETED) + if excess: + conn.execute( + """DELETE FROM async_delegations WHERE delegation_id IN ( + SELECT delegation_id FROM async_delegations + WHERE state NOT IN ('running','finalizing') + ORDER BY CASE delivery_state WHEN 'delivered' THEN 0 ELSE 1 END, + updated_at ASC LIMIT ? + )""", + (excess,), + ) + pending_count = conn.execute( + """SELECT COUNT(*) FROM async_delegations + WHERE state NOT IN ('running','finalizing') AND delivery_state='pending'""" + ).fetchone()[0] + overflow = max(0, pending_count - _MAX_DURABLE_PENDING) + if overflow: + conn.execute( + """DELETE FROM async_delegations WHERE delegation_id IN ( + SELECT delegation_id FROM async_delegations + WHERE state NOT IN ('running','finalizing') AND delivery_state='pending' + ORDER BY updated_at ASC LIMIT ? + )""", + (overflow,), + ) + + +def _persist_completion(event: Dict[str, Any], result: Dict[str, Any]) -> None: + now = time.time() + with _DB_LOCK, _connect() as conn: + conn.execute( + """UPDATE async_delegations SET state=?, completed_at=?, updated_at=?, + event_json=?, result_json=?, delivery_state='pending' + WHERE delegation_id=?""", + (event.get("status", "completed"), event.get("completed_at", now), now, + json.dumps(event), json.dumps(result), event["delegation_id"]), + ) + + +def _note_delivery_attempt(delegation_id: str) -> None: + with _DB_LOCK, _connect() as conn: + conn.execute( + "UPDATE async_delegations SET delivery_attempts=delivery_attempts+1, updated_at=? WHERE delegation_id=?", + (time.time(), delegation_id), + ) + + +def recover_abandoned_delegations() -> int: + """Classify records whose owning process disappeared as outcome unknown.""" + try: + from gateway.status import _pid_exists, get_process_start_time + except Exception: + return 0 + now = time.time() + recovered = 0 + with _DB_LOCK, _connect() as conn: + rows = conn.execute( + """SELECT delegation_id, origin_session, origin_ui_session_id, + parent_session_id, dispatched_at, owner_pid, + owner_started_at, task_json + FROM async_delegations WHERE state IN ('running','finalizing')""" + ).fetchall() + for row in rows: + delegation_id, session_key, origin_ui, parent_id, dispatched_at, pid, started, task_json = row + live = False + if pid: + live = _pid_exists(int(pid)) + if live and started is not None: + live = get_process_start_time(int(pid)) == int(started) + if live: + continue + task = json.loads(task_json or "{}") + event = { + "type": "async_delegation", "delegation_id": delegation_id, + "session_key": session_key, "origin_ui_session_id": origin_ui, + "parent_session_id": parent_id, "goal": task.get("goal", ""), + "goals": task.get("goals"), "context": task.get("context"), + "toolsets": task.get("toolsets"), "role": task.get("role"), + "model": task.get("model"), "is_batch": bool(task.get("is_batch")), + "status": "unknown", "summary": None, + "error": "Delegation owner exited before recording a terminal result; outcome unknown.", + "dispatched_at": dispatched_at, "completed_at": now, + } + result = {"status": "unknown", "summary": None, "error": event["error"]} + conn.execute( + """UPDATE async_delegations SET state='unknown', completed_at=?, + updated_at=?, event_json=?, result_json=?, delivery_state='pending' + WHERE delegation_id=?""", + (now, now, json.dumps(event), json.dumps(result), delegation_id), + ) + recovered += 1 + return recovered + + +def restore_undelivered_completions(target_queue) -> int: + """Enqueue durable pending completions as fresh turns after process start.""" + recover_abandoned_delegations() + with _DB_LOCK, _connect() as conn: + rows = conn.execute( + """SELECT delegation_id, event_json FROM async_delegations + WHERE state != 'running' AND delivery_state='pending' AND event_json IS NOT NULL + ORDER BY completed_at, delegation_id""" + ).fetchall() + for _delegation_id, payload in rows: + target_queue.put(json.loads(payload)) + return len(rows) + + +def mark_completion_delivered(delegation_id: str) -> bool: + """Atomically acknowledge successful injection of a durable completion.""" + now = time.time() + with _DB_LOCK, _connect() as conn: + cur = conn.execute( + """UPDATE async_delegations SET delivery_state='delivered', delivered_at=?, updated_at=? + WHERE delegation_id=? AND delivery_state!='delivered'""", + (now, now, delegation_id), + ) + return cur.rowcount == 1 + + +def claim_completion_delivery(delegation_id: str, claim_id: str) -> bool: + """Claim one pending completion across competing consumers/processes.""" + now = time.time() + with _DB_LOCK, _connect() as conn: + row = conn.execute( + "SELECT delivery_state FROM async_delegations WHERE delegation_id=?", + (delegation_id,), + ).fetchone() + if row is None: + return True # legacy event created before durable dispatch + cur = conn.execute( + """UPDATE async_delegations SET delivery_claim=?, delivery_claimed_at=?, + delivery_attempts=delivery_attempts+1, updated_at=? + WHERE delegation_id=? AND delivery_state='pending' + AND (delivery_claim IS NULL OR delivery_claimed_at < ?)""", + (claim_id, now, now, delegation_id, now - 300), + ) + return cur.rowcount == 1 + + +def claim_event_delivery(evt: Dict[str, Any], consumer: str) -> Optional[str]: + """Claim a durable delegation event; non-durable events need no token.""" + if evt.get("type") != "async_delegation": + return "" + delegation_id = str(evt.get("delegation_id") or "") + if not delegation_id: + return "" + claim_id = f"{consumer}:{__import__('os').getpid()}:{uuid.uuid4().hex}" + return claim_id if claim_completion_delivery(delegation_id, claim_id) else None + + +def release_completion_delivery(delegation_id: str, claim_id: str) -> bool: + """Release a failed delivery claim so another consumer may retry.""" + with _DB_LOCK, _connect() as conn: + cur = conn.execute( + """UPDATE async_delegations SET delivery_claim=NULL, + delivery_claimed_at=NULL, updated_at=? + WHERE delegation_id=? AND delivery_state='pending' + AND delivery_claim=?""", + (time.time(), delegation_id, claim_id), + ) + return cur.rowcount == 1 + + +def complete_completion_delivery(delegation_id: str, claim_id: str) -> bool: + """Acknowledge acceptance for the consumer holding this claim.""" + now = time.time() + with _DB_LOCK, _connect() as conn: + cur = conn.execute( + """UPDATE async_delegations SET delivery_state='delivered', + delivered_at=?, updated_at=?, delivery_claim=NULL, + delivery_claimed_at=NULL + WHERE delegation_id=? AND delivery_state='pending' + AND delivery_claim=?""", + (now, now, delegation_id, claim_id), + ) + return cur.rowcount == 1 + + +def complete_event_delivery(evt: Dict[str, Any], claim_id: str) -> None: + if claim_id and evt.get("type") == "async_delegation": + complete_completion_delivery(str(evt.get("delegation_id") or ""), claim_id) + + +def release_event_delivery(evt: Dict[str, Any], claim_id: str) -> None: + if claim_id and evt.get("type") == "async_delegation": + release_completion_delivery(str(evt.get("delegation_id") or ""), claim_id) + + +def get_durable_delegation(delegation_id: str) -> Optional[Dict[str, Any]]: + with _DB_LOCK, _connect() as conn: + row = conn.execute( + """SELECT origin_session, state, dispatched_at, completed_at, + result_json, delivery_state, delivery_attempts + FROM async_delegations WHERE delegation_id=?""", (delegation_id,), + ).fetchone() + if row is None: + return None + return { + "delegation_id": delegation_id, "origin_session": row[0], "state": row[1], + "dispatched_at": row[2], "completed_at": row[3], + "result": json.loads(row[4]) if row[4] else None, + "delivery_state": row[5], "delivery_attempts": row[6], + } def _get_executor(max_workers: int) -> ThreadPoolExecutor: @@ -96,7 +397,7 @@ def _get_executor(max_workers: int) -> ThreadPoolExecutor: def active_count() -> int: """Number of async delegations currently running.""" with _records_lock: - return sum(1 for r in _records.values() if r.get("status") == "running") + return sum(1 for r in _records.values() if r.get("status") in {"running", "finalizing"}) def _new_delegation_id() -> str: @@ -206,6 +507,7 @@ def dispatch_async_delegation( } _records[delegation_id] = record + _persist_dispatch(record) executor = _get_executor(max_async_children) def _worker() -> None: @@ -234,6 +536,7 @@ def dispatch_async_delegation( except Exception as exc: # pragma: no cover — pool submit failure is rare with _records_lock: _records.pop(delegation_id, None) + _delete_durable_delegation(delegation_id) return { "status": "rejected", "error": f"Failed to schedule async delegation: {exc}", @@ -252,14 +555,20 @@ def _finalize(delegation_id: str, result: Dict[str, Any], status: str) -> None: record = _records.get(delegation_id) if record is None: return - record["status"] = status + # Stay active until durable persistence and queue publication finish; + # otherwise process shutdown can kill this daemon worker in the narrow + # gap after status flips but before SQLite is committed. + record["status"] = "finalizing" record["completed_at"] = time.time() record["interrupt_fn"] = None # drop the closure; child is done - # Snapshot fields needed for the event while holding the lock. event_record = dict(record) - _prune_completed_locked() _push_completion_event(event_record, result, status) + with _records_lock: + record = _records.get(delegation_id) + if record is not None: + record["status"] = status + _prune_completed_locked() def _push_completion_event( @@ -309,6 +618,7 @@ def _push_completion_event( "completed_at": completed_at, "exit_reason": result.get("exit_reason"), } + _persist_completion(evt, result) try: process_registry.completion_queue.put(evt) except Exception as exc: # pragma: no cover @@ -393,6 +703,7 @@ def dispatch_async_delegation_batch( } _records[delegation_id] = record + _persist_dispatch(record) executor = _get_executor(max_async_children) def _worker() -> None: @@ -426,6 +737,7 @@ def dispatch_async_delegation_batch( except Exception as exc: # pragma: no cover with _records_lock: _records.pop(delegation_id, None) + _delete_durable_delegation(delegation_id) return { "status": "rejected", "error": f"Failed to schedule async delegation batch: {exc}", @@ -446,11 +758,10 @@ def _finalize_batch( record = _records.get(delegation_id) if record is None: return - record["status"] = status + record["status"] = "finalizing" record["completed_at"] = time.time() record["interrupt_fn"] = None event_record = dict(record) - _prune_completed_locked() try: from tools.process_registry import process_registry @@ -486,6 +797,7 @@ def _finalize_batch( "dispatched_at": dispatched_at, "completed_at": completed_at, } + _persist_completion(evt, combined) try: process_registry.completion_queue.put(evt) except Exception as exc: # pragma: no cover @@ -494,6 +806,12 @@ def _finalize_batch( "result lost: %s", delegation_id, exc, ) + finally: + with _records_lock: + record = _records.get(delegation_id) + if record is not None: + record["status"] = status + _prune_completed_locked() def list_async_delegations() -> List[Dict[str, Any]]: diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 911e25204ab..24c2cd3325d 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1055,6 +1055,8 @@ def _build_child_agent( override_base_url: Optional[str] = None, override_api_key: Optional[str] = None, override_api_mode: Optional[str] = None, + override_request_overrides: Optional[Dict[str, Any]] = None, + override_max_tokens: Optional[int] = None, # ACP transport overrides from trusted delegation config. override_acp_command: Optional[str] = None, override_acp_args: Optional[List[str]] = None, @@ -1288,16 +1290,33 @@ def _build_child_agent( child_providers_ignored = getattr(parent_agent, "providers_ignored", None) child_providers_order = getattr(parent_agent, "providers_order", None) child_provider_sort = getattr(parent_agent, "provider_sort", None) + child_provider_require_parameters = getattr( + parent_agent, "provider_require_parameters", False + ) + child_provider_data_collection = getattr( + parent_agent, "provider_data_collection", None + ) or "" child_openrouter_min_coding_score = getattr(parent_agent, "openrouter_min_coding_score", None) if override_provider: child_providers_allowed = None child_providers_ignored = None child_providers_order = None child_provider_sort = None + child_provider_require_parameters = False + child_provider_data_collection = "" # Note: openrouter_min_coding_score is model-gated (only emitted on # openrouter/pareto-code), so we keep it inherited even when the # provider is overridden — it's a no-op on any other model. + child_max_tokens = ( + override_max_tokens + if override_max_tokens is not None + else getattr(parent_agent, "max_tokens", None) + ) + child_optional_kwargs: Dict[str, Any] = {} + if isinstance(child_max_tokens, int): + child_optional_kwargs["max_tokens"] = child_max_tokens + child = AIAgent( base_url=effective_base_url, api_key=effective_api_key, @@ -1307,7 +1326,7 @@ def _build_child_agent( acp_command=effective_acp_command, acp_args=effective_acp_args, max_iterations=max_iterations, - max_tokens=getattr(parent_agent, "max_tokens", None), + reasoning_config=child_reasoning, prefill_messages=getattr(parent_agent, "prefill_messages", None), fallback_model=parent_fallback, @@ -1326,9 +1345,17 @@ def _build_child_agent( providers_ignored=child_providers_ignored, providers_order=child_providers_order, provider_sort=child_provider_sort, + provider_require_parameters=child_provider_require_parameters, + provider_data_collection=child_provider_data_collection, + request_overrides=( + dict(override_request_overrides or {}) + if override_provider + else dict(getattr(parent_agent, "request_overrides", {}) or {}) + ), openrouter_min_coding_score=child_openrouter_min_coding_score, tool_progress_callback=child_progress_cb, iteration_budget=None, # fresh budget per subagent + **child_optional_kwargs, ) child._print_fn = getattr(parent_agent, "_print_fn", None) # Now the child exists, its session id can ride on every relayed event @@ -2500,6 +2527,8 @@ def delegate_task( override_base_url=creds["base_url"], override_api_key=creds["api_key"], override_api_mode=creds["api_mode"], + override_request_overrides=creds.get("request_overrides"), + override_max_tokens=creds.get("max_output_tokens"), override_acp_command=creds.get("command"), override_acp_args=creds.get("args"), role=effective_role, @@ -3086,6 +3115,8 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: "base_url": None, "api_key": None, "api_mode": None, + "request_overrides": None, + "max_output_tokens": None, } # Provider is configured — resolve full credentials @@ -3114,6 +3145,8 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: "base_url": runtime.get("base_url"), "api_key": api_key, "api_mode": runtime.get("api_mode"), + "request_overrides": dict(runtime.get("request_overrides") or {}), + "max_output_tokens": runtime.get("max_output_tokens"), "command": runtime.get("command"), "args": list(runtime.get("args") or []), } diff --git a/tools/environments/base.py b/tools/environments/base.py index 93d56c0c5ce..846003432c0 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -321,6 +321,10 @@ class BaseEnvironment(ABC): self._cwd_file = f"{temp_dir}/hermes-cwd-{self._session_id}.txt" self._cwd_marker = _cwd_marker(self._session_id) self._snapshot_ready = False + # When True, login bash is unusable (e.g. broken Git-for-Windows + # ``Directory \\drivers\\etc`` startup) so execute() must not fall + # back to ``bash -l`` per command — use non-login ``bash -c`` instead. + self._prefer_nonlogin = False # ------------------------------------------------------------------ # Abstract methods @@ -367,15 +371,14 @@ class BaseEnvironment(ABC): # Without this the snapshot bootstrap ``cd`` below fails on Windows and # ``pwd -P`` captures the login shell's directory, not ``terminal.cwd``. _quoted_cwd = self._quote_cwd_for_cd(self.cwd) - # Quote the snapshot / cwd-file paths so Git Bash on Windows handles - # ``C:/Users/...``-shaped paths without glob-splitting the colon or - # tripping on drive letters. On POSIX this is a no-op (no colons / - # special chars in a /tmp path). Previously unquoted interpolation - # caused ``C:/Users/.../hermes-snap-*.sh: No such file or directory`` - # errors on Windows, leaking via stderr (merged into stdout on Linux - # backends) into every terminal-tool response. - _quoted_snap = shlex.quote(self._snapshot_path) - _quoted_cwd_file = shlex.quote(self._cwd_file) + # Quote snapshot / cwd-file paths via ``_quote_shell_path`` so the + # LocalEnvironment override can rewrite ``C:/...`` (and mixed + # ``/c/Users\\...``) to ``/c/...`` before quoting — bare drive paths + # in the bootstrap script trip MSYS into the + # ``Directory \\drivers\\etc does not exist`` failure class. + # On POSIX this is plain ``shlex.quote``. + _quoted_snap = self._quote_shell_path(self._snapshot_path) + _quoted_cwd_file = self._quote_shell_path(self._cwd_file) # Use atomic file replacement: assemble the snapshot in a temp file, # then mv it over the final path. This prevents concurrent source() # calls from reading a half-written snapshot when another terminal @@ -391,9 +394,9 @@ class BaseEnvironment(ABC): # mid-write, and mv would then publish a torn file (the corruption is # only narrowed, not closed). ``$BASHPID`` is the actual subshell PID # and is genuinely unique per writer, which closes the race. The - # static path is shlex-quoted (Windows/Git-Bash drive letters, spaces) + # static path is shell-quoted (Windows/Git-Bash drive letters, spaces) # with ``$BASHPID`` left outside the quotes so it still expands. - _snap_tmp = shlex.quote(self._snapshot_path + ".tmp.") + "$BASHPID" + _snap_tmp = self._quote_shell_path(self._snapshot_path + ".tmp.") + "$BASHPID" bootstrap = ( f"umask 077\n" f"export -p > {_snap_tmp}\n" @@ -438,13 +441,37 @@ class BaseEnvironment(ABC): self.cwd, ) except Exception as exc: - logger.warning( - "init_session failed (session=%s): %s — " - "falling back to bash -l per command", - self._session_id, - exc, - ) self._snapshot_ready = False + # Default fallback is bash -l per command so PATH/nvm/etc still + # load. If login itself is dead (classic Windows Git Bash + # ``Directory \\drivers\\etc does not exist``), that fallback + # would brick every tool — prefer non-login bash -c instead. + detail = str(exc) + prefer_nonlogin = False + try: + probe = self._run_bash("true", login=False, timeout=min(15, self._snapshot_timeout)) + probe_result = self._wait_for_process(probe, timeout=min(15, self._snapshot_timeout)) + prefer_nonlogin = int(probe_result.get("returncode") or 0) == 0 + if not prefer_nonlogin: + detail = (probe_result.get("stdout") or detail).strip() or detail + except Exception as probe_exc: + detail = f"{detail}; non-login probe: {probe_exc}" + + self._prefer_nonlogin = prefer_nonlogin + if prefer_nonlogin: + logger.warning( + "init_session failed (session=%s): %s — " + "login bash unusable; falling back to non-login bash -c", + self._session_id, + exc, + ) + else: + logger.warning( + "init_session failed (session=%s): %s — " + "falling back to bash -l per command", + self._session_id, + detail, + ) # ------------------------------------------------------------------ # Command wrapping @@ -461,25 +488,32 @@ class BaseEnvironment(ABC): return f"$HOME/{shlex.quote(cwd[2:])}" return shlex.quote(cwd) + def _quote_shell_path(self, path: str) -> str: + """Quote *path* for interpolation into a bash script. + + LocalEnvironment overrides this to rewrite native/mixed Windows + paths to ``/c/...`` before quoting. Remote backends leave paths + as-is (they already speak POSIX). + """ + return shlex.quote(path) + def _wrap_command(self, command: str, cwd: str) -> str: """Build the full bash script that sources snapshot, cd's, runs command, re-dumps env vars, and emits CWD markers.""" escaped = command.replace("'", "'\\''") - # Quote the snapshot / cwd-file paths so Git Bash on Windows handles - # ``C:/Users/...``-shaped paths without glob-splitting the colon or - # tripping on drive letters. POSIX paths are unaffected. See - # :meth:`init_session` for the same fix on the bootstrap block. - _quoted_snap = shlex.quote(self._snapshot_path) - _quoted_cwd_file = shlex.quote(self._cwd_file) + # Quote snapshot/cwd-file paths (see init_session — LocalEnvironment + # rewrites ``C:/...`` to ``/c/...`` so MSYS doesn't mangle them). + _quoted_snap = self._quote_shell_path(self._snapshot_path) + _quoted_cwd_file = self._quote_shell_path(self._cwd_file) # Use atomic file replacement for env snapshot updates (issue #38249). # Assemble into a per-writer-unique temp file, then mv to atomically # replace the snapshot so concurrent source() calls never read a # truncated/half-written file. ``$BASHPID`` (not ``$$``) is the actual # subshell PID — unique per concurrent ``&``-launched writer — so two # writers never share a temp name and clobber each other before the mv. - # Static path shlex-quoted (Windows/spaces); ``$BASHPID`` left to expand. - _snap_tmp = shlex.quote(self._snapshot_path + ".tmp.") + "$BASHPID" + # Static path shell-quoted (Windows/spaces); ``$BASHPID`` left to expand. + _snap_tmp = self._quote_shell_path(self._snapshot_path + ".tmp.") + "$BASHPID" parts = [] @@ -927,8 +961,9 @@ class BaseEnvironment(ABC): wrapped = self._wrap_command(exec_command, effective_cwd) - # Use login shell if snapshot failed (so user's profile still loads) - login = not self._snapshot_ready + # Use login shell if snapshot failed (so user's profile still loads), + # unless login itself is broken — then non-login is the only path. + login = not self._snapshot_ready and not self._prefer_nonlogin proc = self._run_bash( wrapped, login=login, timeout=effective_timeout, stdin_data=effective_stdin diff --git a/tools/environments/local.py b/tools/environments/local.py index 191ff4d4b2d..0a867fa984b 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -25,16 +25,24 @@ def _msys_to_windows_path(cwd: str) -> str: native Windows form (``C:\\Users\\x``) so ``os.path.isdir`` and ``subprocess.Popen(..., cwd=...)`` can find it. + Also accepts the Cygwin (``/cygdrive/c/...``) and WSL-mount + (``/mnt/c/...``) spellings of a drive root. Multi-segment POSIX paths + like ``/home/x`` or ``/tmp/foo`` are left untouched. + No-ops on non-Windows hosts or for paths that aren't in MSYS form. Returns the input unchanged when no translation applies. This is idempotent — calling it on an already-Windows path returns it as-is. """ if not _IS_WINDOWS or not cwd: return cwd - # Match leading "//" or exactly "/" (bare drive root). - m = re.match(r'^/([a-zA-Z])(/.*)?$', cwd) + # Match leading "//" or exactly "/" (bare drive root), + # plus /cygdrive//... and /mnt//... variants. + m = re.match(r'^/(?:(?:cygdrive|mnt)/)?([a-zA-Z])(/.*)?$', cwd) if not m: return cwd + # Reject /cygdrive or /mnt with no drive letter — the optional group above + # already requires the letter. Multi-char first segments (/home, /tmp) + # fail the single-letter capture and fall through as no-ops. drive = m.group(1).upper() tail = (m.group(2) or "").replace('/', '\\') return f"{drive}:{tail or chr(92)}" # chr(92) = backslash, avoid raw-string escape @@ -58,6 +66,35 @@ def _windows_to_msys_path(cwd: str) -> str: return f"/{drive}/{tail}" if tail else f"/{drive}/" +def _bash_safe_path(path: str) -> str: + """Return *path* in a form safe to embed in a Git Bash script. + + Native ``C:\\Users\\x`` / ``C:/Users/x`` → ``/c/Users/x`` via + :func:`_windows_to_msys_path`. Mixed MSYS leftovers + (``/c/Users\\Alexander\\Documents``) get backslashes normalized so + bash does not eat ``\\U`` and trip the ``Directory \\drivers\\etc`` + failure class. No-op off Windows and for empty input. + + ``get_temp_dir`` already emits forward-slash ``C:/...`` forms for + Python compatibility; those still need the ``/c/...`` rewrite — + MSYS argument conversion treats ``C:/...`` as a Windows path and + can corrupt the login-shell ``drivers\\etc`` lookup. + """ + if not _IS_WINDOWS or not path: + return path + path = _windows_to_msys_path(path) + if "\\" in path: + path = path.replace("\\", "/") + return path + + +def _quote_bash_path(path: str) -> str: + """Quote *path* for safe interpolation into a Git Bash script on Windows.""" + import shlex + + return shlex.quote(_bash_safe_path(path)) + + def _resolve_safe_cwd(cwd: str) -> str: """Return ``cwd`` if it exists as a directory, else the nearest existing ancestor. Falls back to ``tempfile.gettempdir()`` only if walking up the @@ -521,14 +558,15 @@ def _find_bash() -> str: or "/bin/sh" ) + candidates: list[str] = [] + custom = os.environ.get("HERMES_GIT_BASH_PATH") if custom and os.path.isfile(custom): - return custom + candidates.append(custom) - # Prefer our own portable Git install first — this way a broken or - # partially-uninstalled system Git can't hijack the bash lookup. The - # install.ps1 installer always drops portable Git here when the user - # didn't already have a working system Git. + # Prefer our own portable Git install — a broken or partially-uninstalled + # system Git (or a stale HERMES_GIT_BASH_PATH pointing at one) must not + # brick the terminal. install.ps1 drops PortableGit here when needed. # # Layouts (both checked so upgrades between MinGit and PortableGit # installs work transparently): @@ -541,8 +579,8 @@ def _find_bash() -> str: os.path.join(_hermes_portable_git, "bin", "bash.exe"), # PortableGit (primary) os.path.join(_hermes_portable_git, "usr", "bin", "bash.exe"), # MinGit fallback ): - if os.path.isfile(candidate): - return candidate + if os.path.isfile(candidate) and candidate not in candidates: + candidates.append(candidate) # Check known Git for Windows install locations before PATH lookup. # On machines with both WSL and Git for Windows, shutil.which("bash") @@ -551,14 +589,33 @@ def _find_bash() -> str: for candidate in ( os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Git", "bin", "bash.exe"), os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Git", "bin", "bash.exe"), - os.path.join(_local_appdata, "Programs", "Git", "bin", "bash.exe"), + os.path.join(_local_appdata, "Programs", "Git", "bin", "bash.exe") if _local_appdata else "", ): - if candidate and os.path.isfile(candidate): - return candidate + if candidate and os.path.isfile(candidate) and candidate not in candidates: + candidates.append(candidate) found = shutil.which("bash") - if found: - return found + if found and found not in candidates: + candidates.append(found) + + # Prefer the first candidate that can actually start. A stale + # HERMES_GIT_BASH_PATH pointing at a broken Git-for-Windows install + # (``Directory \\drivers\\etc does not exist``) must not win over a + # healthy portable Git under %LOCALAPPDATA%\\hermes\\git. + for candidate in candidates: + if _bash_starts(candidate): + if candidate != custom and custom and os.path.isfile(custom): + logger.warning( + "HERMES_GIT_BASH_PATH=%s fails to start; using %s instead", + custom, + candidate, + ) + return candidate + + if candidates: + # Last resort: return the first path even if the probe failed, so the + # caller still sees the real bash error instead of "not found". + return candidates[0] raise RuntimeError( "Git Bash not found. Hermes Agent requires Git for Windows on Windows.\n" @@ -567,6 +624,116 @@ def _find_bash() -> str: ) +_bash_starts_cache: dict[str, bool] = {} + + +def _bash_starts(bash: str) -> bool: + """True if *bash* can run a trivial non-login command. + + Uses ``--noprofile --norc`` so a broken login post-install + (``Directory \\drivers\\etc``) does not falsely condemn an otherwise + usable bash. Cached per path for the process lifetime. + """ + cached = _bash_starts_cache.get(bash) + if cached is not None: + return cached + + try: + result = subprocess.run( + [bash, "--noprofile", "--norc", "-c", "exit 0"], + capture_output=True, + text=True, + timeout=15, + creationflags=windows_hide_flags() if _IS_WINDOWS else 0, + ) + ok = result.returncode == 0 + if not ok: + combined = f"{result.stdout or ''}{result.stderr or ''}" + logger.debug("bash probe failed for %s: %s", bash, combined.strip()[:200]) + except Exception as exc: + logger.debug("bash probe error for %s: %s", bash, exc) + ok = False + + _bash_starts_cache[bash] = ok + return ok + + +_git_bash_bin_dirs_cache: "list[str] | None" = None + + +def _git_bash_bin_dirs() -> list[str]: + """Git Bash's coreutils/binary dirs, in ``/etc/profile`` precedence order. + + A non-login ``bash -c`` (the fallback used when ``bash -l`` is broken — + the classic Windows ``Directory \\drivers\\etc does not exist`` failure) + never sources ``/etc/profile``, so it never gets ``…\\usr\\bin`` on PATH. + That directory holds every coreutil the file/terminal tools shell out to + (``cat``, ``mktemp``, ``mv``, ``wc``, ``head``, ``stat``, ``chmod``, + ``mkdir``, ``find`` …). Without it, ``write_file`` fails with an empty + error (the failure text went to a missing binary's stderr) and terminal + commands exit 127. We derive these dirs from the resolved ``bash.exe`` so + the fallback shell can find coreutils regardless of the login shell. + + Returns ``[]`` off Windows or when bash can't be located. Dirs are + returned in the order Git Bash's own ``/etc/profile`` prepends them + (mingw first, then usr/bin, then bin) and only if they exist on disk. + """ + global _git_bash_bin_dirs_cache + if _git_bash_bin_dirs_cache is not None: + return _git_bash_bin_dirs_cache + + if not _IS_WINDOWS: + _git_bash_bin_dirs_cache = [] + return _git_bash_bin_dirs_cache + + dirs: list[str] = [] + try: + bash = _find_bash() + except Exception: + _git_bash_bin_dirs_cache = [] + return _git_bash_bin_dirs_cache + + bin_dir = os.path.dirname(bash) # \bin or \usr\bin + parent = os.path.dirname(bin_dir) + # MinGit ships bash under usr\bin; PortableGit/system Git under bin. + root = os.path.dirname(parent) if os.path.basename(parent).lower() == "usr" else parent + + # Order mirrors Git-for-Windows /etc/profile so coreutils win over the + # same-named Windows System32 tools (find.exe, sort.exe) inside the shell. + for candidate in ( + os.path.join(root, "mingw64", "bin"), + os.path.join(root, "mingw32", "bin"), + os.path.join(root, "usr", "local", "bin"), + os.path.join(root, "usr", "bin"), + os.path.join(root, "bin"), + ): + if os.path.isdir(candidate) and candidate not in dirs: + dirs.append(candidate) + + _git_bash_bin_dirs_cache = dirs + return dirs + + +def _prepend_git_bash_dirs(existing_path: str) -> str: + """Prepend Git Bash's binary dirs to ``existing_path`` if missing. + + No-op off Windows or when the dirs can't be resolved. First-occurrence + wins, so a PATH that already lists a dir keeps its position. This is what + lets the non-login ``bash -c`` fallback find coreutils; in the healthy + case the session snapshot re-exports the full login PATH inside the shell, + so this only matters when that snapshot is absent. + """ + git_dirs = _git_bash_bin_dirs() + if not git_dirs: + return existing_path + sep = os.pathsep + entries = [e for e in existing_path.split(sep) if e] if existing_path else [] + missing = [d for d in git_dirs if d not in entries] + if not missing: + return existing_path + return sep.join([*missing, *entries]) + + # POSIX-sh-family shells that understand the ``[shell, "-lic", "set +m; …"]`` # invocation spawn_local uses. $SHELL values outside this set (fish, csh/tcsh, # nushell, elvish, xonsh, …) would error on that syntax, so _find_shell falls @@ -813,6 +980,13 @@ def _make_run_env(env: dict) -> dict: path_key = _path_env_key(run_env) if path_key is not None: new_path = _append_missing_sane_path_entries(run_env.get(path_key, "")) + # On Windows, ensure Git Bash's coreutils dirs (…\usr\bin etc.) are on + # PATH. A non-login ``bash -c`` fallback (used when ``bash -l`` is + # broken) never sources /etc/profile, so without this cat/mktemp/mv and + # friends are missing and every write_file/terminal call fails (empty + # error / exit 127). No-op off Windows and when a login snapshot is + # healthy (the snapshot re-exports the full PATH inside the shell). + new_path = _prepend_git_bash_dirs(new_path) # Ensure the hermes install dir is reachable so plugins can shell out # to bare ``hermes`` via the terminal tool even when the gateway was # launched without it on PATH (systemd, service managers, cron, etc.). @@ -986,6 +1160,10 @@ class LocalEnvironment(BaseEnvironment): """Use native paths for Python, but Git Bash-friendly paths for cd.""" return BaseEnvironment._quote_cwd_for_cd(_windows_to_msys_path(cwd)) + def _quote_shell_path(self, path: str) -> str: + """Rewrite native/mixed Windows paths before quoting for Git Bash.""" + return _quote_bash_path(path) + def _run_bash(self, cmd_string: str, *, login: bool = False, timeout: int = 120, stdin_data: str | None = None) -> subprocess.Popen: diff --git a/tools/file_operations.py b/tools/file_operations.py index 35b60b0b741..76446befaa5 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -958,7 +958,19 @@ 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 native drive paths (``C:\\Users\\x`` / ``C:/Users/x``) + and mixed MSYS leftovers (``/c/Users\\x``) are rewritten to the + Git Bash ``/c/Users/x`` form via ``_bash_safe_path``: bash eats + backslashes and MSYS otherwise mangles drive paths into the + ``Directory \\drivers\\etc does not exist`` failure class. Reuses + the env-layer translator so shell file ops and the terminal ``cd`` + agree on the path form. No-op off Windows and for plain POSIX paths. + """ + from tools.environments.local import _bash_safe_path + + arg = _bash_safe_path(arg) # Use single quotes and escape any single quotes in the string return "'" + arg.replace("'", "'\"'\"'") + "'" diff --git a/tools/file_tools.py b/tools/file_tools.py index 61e6ecc588d..e602e8e0a67 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -6,6 +6,7 @@ import json import logging import os import posixpath +import sys import threading from pathlib import Path, PurePosixPath @@ -406,6 +407,17 @@ def _resolve_base_dir( if not posixpath.isabs(base_text): base_text = posixpath.join(os.getcwd(), base_text) return _normalize_without_host_deref(base_text) + # Git Bash ``pwd -P`` reports ``/c/Users/...``; translate before Path so + # relative file-tool paths don't anchor under a nonexistent ``\\c\\Users``. + from tools.environments.local import _msys_to_windows_path + + base_text = _msys_to_windows_path(base_text) + if sys.platform == "win32": + import ntpath + + if not ntpath.isabs(base_text): + base_text = ntpath.join(os.getcwd(), base_text) + return Path(ntpath.normpath(base_text)) base = Path(base_text) if not base.is_absolute(): # Last-resort anchoring: a live cwd should already be absolute, but if a @@ -420,14 +432,31 @@ def _resolve_path_for_task(filepath: str, task_id: str = "default") -> Path | Pu See :func:`_resolve_base_dir` for how the base is chosen. Absolute input paths are returned resolved-but-unanchored. + + On native Windows, Git Bash / MSYS drive paths (``/c/Users/...``) are + translated to ``C:\\Users\\...`` before resolution so file tools don't + treat them as relative ``\\c\\Users\\...`` under the process cwd. """ container_paths = _uses_container_paths(task_id) - expanded = _expand_tilde(filepath) if container_paths: + expanded = _expand_tilde(filepath) if posixpath.isabs(expanded): return _normalize_without_host_deref(expanded) resolved = _resolve_base_dir(task_id, container_paths=True) / expanded return _normalize_without_host_deref(resolved) + + # Host paths only — never rewrite Linux paths inside a container/WSL env. + from tools.environments.local import _msys_to_windows_path + + expanded = _expand_tilde(_msys_to_windows_path(filepath)) + if sys.platform == "win32": + import ntpath + + if ntpath.isabs(expanded): + return Path(ntpath.normpath(expanded)) + joined = ntpath.join(str(_resolve_base_dir(task_id, container_paths=False)), expanded) + return Path(ntpath.normpath(joined)) + p = Path(expanded) if p.is_absolute(): return p.resolve() diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 7806db57efb..ff46518bd3f 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -1084,18 +1084,7 @@ def _build_no_backend_setup_message() -> str: def check_image_generation_requirements() -> bool: - """True if any image gen backend is available. - - Providers are considered in this order: - - 1. The in-tree FAL backend (FAL_KEY or managed gateway). - 2. Any plugin-registered provider whose ``is_available()`` returns True. - - Plugins win only when the in-tree FAL path is NOT ready, which matches - the historical behavior: shipping hermes with a FAL key configured - should still expose the tool. The active selection among ready - providers is resolved per-call by ``image_gen.provider``. - """ + """True if FAL or the explicitly configured image backend is available.""" try: if check_fal_api_key(): # Trigger the lazy fal_client import here as the SDK presence @@ -1107,22 +1096,21 @@ def check_image_generation_requirements() -> bool: except ImportError: pass - # Probe plugin providers. Discovery is idempotent and cheap. + configured = _read_configured_image_provider() + if not configured or configured == "fal": + return False + + # Probe only the explicitly selected plugin. Merely possessing a cloud + # provider key must not opt a user into a paid image-generation backend. try: - from agent.image_gen_registry import list_providers + from agent.image_gen_registry import get_provider from hermes_cli.plugins import _ensure_plugins_discovered _ensure_plugins_discovered() - for provider in list_providers(): - try: - if provider.is_available(): - return True - except Exception: - continue + provider = get_provider(configured) + return bool(provider and provider.is_available()) except Exception: - pass - - return False + return False # --------------------------------------------------------------------------- @@ -1248,7 +1236,7 @@ def _read_configured_image_model(): def _read_configured_image_provider(): - """Return the value of ``image_gen.provider`` from config.yaml, or None. + """Return ``image_gen.provider`` from config.yaml, or None. We only consult the plugin registry when this is explicitly set — an unset value keeps users on the in-tree FAL fallback even when other @@ -1293,8 +1281,8 @@ def _dispatch_to_plugin_provider( route to its edit endpoint. """ configured = _read_configured_image_provider() - if not configured: - return None + if not configured or configured == "fal": + return None # unset/explicit FAL keeps the legacy FAL path # Also read configured model so we can pass it to the plugin configured_model = _read_configured_image_model() diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 2e81f94429e..5207c756aa6 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -630,6 +630,13 @@ def _handle_complete(args: dict, **kw) -> str: created_cards=created_cards, expected_run_id=_worker_run_id(tid), ) + except kb.ArtifactPreservationError as artifact_err: + return tool_error( + f"kanban_complete could not preserve the declared artifacts: " + f"{artifact_err}. Your task is still in-flight and its " + f"scratch workspace was kept. Fix the artifact path or " + f"storage error, then retry kanban_complete with the same handoff." + ) except kb.HallucinatedCardsError as hall_err: # Structured rejection — surface the phantom ids so the # worker can retry with a corrected list or drop the @@ -1277,8 +1284,10 @@ KANBAN_COMPLETE_SCHEMA = { "lands with the completion notification. Skip " "intermediate scratch files and references that " "are not the deliverable. The path must exist " - "on disk when the notifier runs; missing files " - "are silently skipped." + "on disk at completion. Files inside a managed scratch " + "workspace are copied to durable task attachments before " + "cleanup; a missing declared scratch artifact keeps the " + "task in-flight so you can fix the path and retry." ), }, "board": _board_schema_prop(), diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 94cd069b8d3..d35feb861cf 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -188,7 +188,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { "qrcode==7.4.2", ), "platform.feishu": ( - "lark-oapi==1.5.3", + "lark-oapi==1.6.8", "qrcode==7.4.2", ), # WeCom callback-mode adapter — parses untrusted XML POST bodies. Pulls diff --git a/tools/patch_parser.py b/tools/patch_parser.py index e16cb446ee0..c02037f77ca 100644 --- a/tools/patch_parser.py +++ b/tools/patch_parser.py @@ -253,8 +253,11 @@ def _validate_operations( from tools.fuzzy_match import fuzzy_find_and_replace errors: List[str] = [] + real_change_count = 0 for op in operations: + if op.operation != OperationType.UPDATE: + real_change_count += 1 if op.operation == OperationType.UPDATE: read_result = file_ops.read_file_raw(op.file_path) if read_result.error: @@ -262,8 +265,15 @@ def _validate_operations( continue simulated = read_result.content - for hunk in op.hunks: + for hunk_index, hunk in enumerate(op.hunks, start=1): search_lines = [l.content for l in hunk.lines if l.prefix in {' ', '-'}] + removed_lines = [l.content for l in hunk.lines if l.prefix == '-'] + added_lines = [l.content for l in hunk.lines if l.prefix == '+'] + if not removed_lines and not added_lines: + # Models occasionally emit inert anchor hunks between real + # changes. Ignore them without poisoning the atomic patch. + continue + real_change_count += 1 if not search_lines: # Addition-only hunk: validate context hint uniqueness if hunk.context_hint: @@ -291,7 +301,7 @@ def _validate_operations( if count == 0: label = f"'{hunk.context_hint}'" if hunk.context_hint else "(no hint)" msg = ( - f"{op.file_path}: hunk {label} not found" + f"{op.file_path}: hunk {hunk_index} {label} not found" + (f" — {match_error}" if match_error else "") ) try: @@ -325,6 +335,9 @@ def _validate_operations( # ADD: parent directory creation handled by write_file; no pre-check needed. + if not errors and real_change_count == 0: + errors.append("Patch contains no changes (only context lines were provided)") + return errors @@ -545,6 +558,8 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str, Optiona elif line.prefix == '+': replace_lines.append(line.content) + if search_lines and search_lines == replace_lines: + continue if search_lines: search_pattern = '\n'.join(search_lines) replacement = '\n'.join(replace_lines) diff --git a/tools/process_registry.py b/tools/process_registry.py index 0faa80ba6e2..f7e6e8471b5 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -171,6 +171,13 @@ class ProcessRegistry: # gateway drain this after each agent turn to auto-trigger new turns. import queue as _queue_mod self.completion_queue: _queue_mod.Queue = _queue_mod.Queue() + # Rehydrate durable delegation completions only at registry startup. + # Consumers still inject them as fresh turns through this existing rail. + try: + from tools.async_delegation import restore_undelivered_completions + restore_undelivered_completions(self.completion_queue) + except Exception as exc: + logger.warning("Could not restore async delegation completions: %s", exc) # Track sessions whose completion was already consumed by the agent # via wait/log. Drain loops AND gateway/tui watchers skip notifications @@ -1091,6 +1098,10 @@ class ProcessRegistry: "completion_reason": session.completion_reason, "termination_source": session.termination_source, "output": output_tail, + # Stable producer identity across checkpoint recovery; unlike + # a consumer-observed completion timestamp, this does not vary + # based on which watcher notices exit first. + "started_at": session.started_at, }) # ----- Query Methods ----- @@ -1347,8 +1358,13 @@ class ProcessRegistry: # Default: last N lines if offset == 0 and limit > 0: selected = lines[-limit:] + observed_completion_output = bool(selected) or total_lines == 0 else: selected = lines[offset:offset + limit] + stop = slice(offset, offset + limit).indices(total_lines)[1] + observed_completion_output = ( + total_lines == 0 or (bool(selected) and stop == total_lines) + ) result = { "session_id": session.id, @@ -1358,7 +1374,7 @@ class ProcessRegistry: "total_lines": total_lines, "showing": f"{len(selected)} lines", } - if session.exited: + if session.exited and observed_completion_output: self._completion_consumed.add(session_id) return result @@ -1449,17 +1465,41 @@ class ProcessRegistry: result["timeout_note"] = f"Waited {effective_timeout}s, process still running" return result - def kill_process(self, session_id: str, *, source: str = "process.kill") -> dict: - """Kill a background process.""" + def kill_process( + self, + session_id: str, + *, + source: str = "process.kill", + consume_output: bool = True, + ) -> dict: + """Kill a background process and return its output snapshot. + + ``consume_output`` is true for explicit tool/RPC kills because their + caller observes the returned output. Bulk cleanup passes false: it + discards each result and therefore must not suppress an autonomous + output-bearing completion notification. + """ + from tools.ansi_strip import strip_ansi + session = self.get(session_id) if session is None: return {"status": "not_found", "error": f"No process with ID {session_id}"} if session.exited: - return { - "status": "already_exited", - "exit_code": session.exit_code, - } + with session._lock: + result = { + "status": "already_exited", + "command": session.command, + "exit_code": session.exit_code, + "completion_reason": session.completion_reason, + "termination_source": session.termination_source, + "output": strip_ansi(session.output_buffer[-2000:]), + } + # Only suppress the autonomous turn after its output is present in + # the explicit kill result, matching wait/log consumption. + if consume_output: + self._completion_consumed.add(session_id) + return result # Kill via PTY, Popen (local), or env execute (non-local) try: @@ -1486,10 +1526,14 @@ class ProcessRegistry: with session._lock: session.exited = True session.exit_code = None + output = strip_ansi(session.output_buffer[-2000:]) + if consume_output: + self._completion_consumed.add(session_id) self._move_to_finished(session) return { "status": "already_exited", "exit_code": session.exit_code, + "output": output, } self._terminate_host_pid(session.pid, session.host_start_time) else: @@ -1500,10 +1544,17 @@ class ProcessRegistry: "its original runtime handle is no longer available" ), } - session.exited = True - session.exit_code = -15 # SIGTERM - session.completion_reason = "killed" - session.termination_source = source + # Capture output before marking consumed, then mark consumed before + # exposing ``exited`` to watcher tasks. This closes the delayed + # notification race without discarding the terminal transcript. + with session._lock: + output = strip_ansi(session.output_buffer[-2000:]) + if consume_output: + self._completion_consumed.add(session_id) + session.exited = True + session.exit_code = -15 # SIGTERM + session.completion_reason = "killed" + session.termination_source = source self._move_to_finished(session) self._write_checkpoint() return { @@ -1511,6 +1562,7 @@ class ProcessRegistry: "session_id": session.id, "completion_reason": session.completion_reason, "termination_source": session.termination_source, + "output": output, } except Exception as e: return {"status": "error", "error": str(e)} @@ -1747,7 +1799,11 @@ class ProcessRegistry: killed = 0 for session in targets: - result = self.kill_process(session.id, source="kill_all") + result = self.kill_process( + session.id, + source="kill_all", + consume_output=False, + ) if result.get("status") in {"killed", "already_exited"}: killed += 1 return killed @@ -2238,7 +2294,10 @@ def _handle_process(args, **kw): elif action == "wait": return json.dumps(_redact_process_result(process_registry.wait(session_id, timeout=args.get("timeout"))), ensure_ascii=False) elif action == "kill": - return json.dumps(process_registry.kill_process(session_id), ensure_ascii=False) + return json.dumps( + _redact_process_result(process_registry.kill_process(session_id)), + ensure_ascii=False, + ) elif action == "write": return json.dumps(process_registry.write_stdin(session_id, str(args.get("data", ""))), ensure_ascii=False) elif action == "submit": diff --git a/tools/registry.py b/tools/registry.py index 35589bd2c84..354da7123fd 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -45,11 +45,20 @@ def _module_registers_tools(module_path: Path) -> bool: Only inspects module-body statements so that helper modules which happen to call ``registry.register()`` inside a function are not picked up. + + A cheap text prefilter avoids the ``ast.parse`` cost for files that do not + mention both ``registry`` and ``register`` — a necessary condition for a + top-level ``registry.register()`` call to exist. """ try: source = module_path.read_text(encoding="utf-8") + except OSError: + return False + if "registry" not in source or "register" not in source: + return False + try: tree = ast.parse(source, filename=str(module_path)) - except (OSError, SyntaxError): + except SyntaxError: return False return any(_is_registry_register_call(stmt) for stmt in tree.body) @@ -571,10 +580,43 @@ class ToolRegistry: # Dispatch # ------------------------------------------------------------------ - def dispatch(self, name: str, args: dict, **kwargs) -> str: + @staticmethod + def _normalize_handler_result(name: str, result): + """Enforce the result shapes supported by the agent tool pipeline. + + Normal tool results are strings. The sole structured exception is the + multimodal envelope consumed by the agent executor. Returning every + other value as a string error keeps logging, hooks, budgeting, and + persistence from receiving values they cannot safely slice or size. + """ + if isinstance(result, str): + return result + if ( + isinstance(result, dict) + and result.get("_multimodal") is True + and isinstance(result.get("content"), list) + ): + return result + + result_type = type(result).__name__ + logger.error( + "Tool %s handler returned unsupported result type: %s", + name, + result_type, + ) + return json.dumps({ + "error": f"Tool handler returned unsupported result type: {result_type}", + "error_type": "tool_result_contract", + "tool": name, + "result_type": result_type, + }, ensure_ascii=False) + + def dispatch(self, name: str, args: dict, **kwargs) -> str | dict: """Execute a tool handler by name. * Async handlers are bridged automatically via ``_run_async()``. + * Handler results are normalized to a string or supported multimodal + envelope before leaving the registry. * All exceptions are caught and returned as ``{"error": "..."}`` for consistent error format. """ @@ -584,8 +626,10 @@ class ToolRegistry: try: if entry.is_async: from model_tools import _run_async - return _run_async(entry.handler(args, **kwargs)) - return entry.handler(args, **kwargs) + result = _run_async(entry.handler(args, **kwargs)) + else: + result = entry.handler(args, **kwargs) + return self._normalize_handler_result(name, result) except Exception as e: logger.exception("Tool %s dispatch error: %s", name, e) # Route through the sanitizer so framing tokens / CDATA / fences diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 5b0714b0ba1..c143ea8064f 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -65,6 +65,62 @@ _VOICE_EXTS = {".ogg", ".opus"} # formats either route through sendVoice (Opus/OGG) or fall back to # document delivery. _TELEGRAM_SEND_AUDIO_EXTS = {".mp3", ".m4a"} + +# Extensions that carry a native caption on the media bubble itself +# (photo/video/document). Voice/audio notes are excluded: a caption on a +# voice note reads as a separate label rather than a bubble caption, and the +# established convention is to keep the accompanying text as its own message. +_CAPTIONABLE_EXTS = _IMAGE_EXTS | _VIDEO_EXTS | { + ".pdf", ".doc", ".docx", ".txt", ".md", ".csv", ".xlsx", ".zip", +} + +# Per-platform native caption length limits (characters). Text longer than +# the limit can't ride on the media bubble and stays a separate body message. +# Telegram's photo/video caption cap is 1024; WhatsApp and Discord are far +# more generous, so a conservative shared ceiling keeps behavior predictable. +_TELEGRAM_CAPTION_LIMIT = 1024 +_DEFAULT_CAPTION_LIMIT = 4096 + + +def _media_caption_split(text, media_files, *, max_caption_len): + """Decide whether the accompanying text should ride on the media bubble. + + Single enforced chokepoint for the ``MEDIA: caption`` behavior + across every standalone sender. ``hermes send`` (and the send_message + tool / cron) strips the ``MEDIA:`` tag and leaves the remaining prose as + ``text``; historically each platform sent that ``text`` as a *separate* + message before an uncaptioned media bubble, splitting the reported case + ``hermes send --to whatsapp "MEDIA:/x.png This Caption"`` into two parts. + + Returns ``(caption, body_text)``: + + * ``(caption, "")`` — attach ``text`` to the media as its native caption + and send *no* separate body message. Only when there is exactly one + media file, it is a captionable kind (image/video/document, not a + voice/audio note), and ``text`` fits ``max_caption_len``. + * ``(None, text)`` — keep the historical behavior: ``text`` is a separate + body message and the media carries no caption. Applies to multi-file + sends (caption→file association is ambiguous), voice/audio notes, empty + text, or text longer than the caption limit. + """ + stripped = (text or "").strip() + media = media_files or [] + if not stripped or len(media) != 1: + return None, text + media_path, is_voice = media[0] + if is_voice: + return None, text + ext = os.path.splitext(media_path)[1].lower() + if ext not in _CAPTIONABLE_EXTS: + return None, text + # Measure the caption in Unicode codepoints — a portable upper bound that + # never under-counts vs Telegram's UTF-16 units for BMP text, so an + # over-count only fails safe (falls back to a separate message). The + # Telegram call site additionally re-checks the *formatted* caption in + # UTF-16 units, since MarkdownV2/HTML escaping can inflate the length. + if len(stripped) > max_caption_len: + return None, text + return stripped, "" _URL_SECRET_QUERY_RE = re.compile( r"([?&](?:access_token|api[_-]?key|auth[_-]?token|token|signature|sig)=)([^&#\s]+)", re.IGNORECASE, @@ -814,6 +870,26 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, entry = platform_registry.get("discord") if entry is None or entry.standalone_sender_fn is None: return {"error": "Discord plugin not registered or missing standalone_sender_fn"} + # MEDIA: caption: single captionable file + short text rides as + # the media message content instead of a separate message before the + # attachment (single enforced decision in _media_caption_split). Cap on + # the platform's own message limit so the caption is always deliverable. + _dc_caption, _ = _media_caption_split( + message, media_files, + max_caption_len=(max_len or _DEFAULT_CAPTION_LIMIT), + ) + if _dc_caption is not None: + result = await entry.standalone_sender_fn( + pconfig, + chat_id, + "", + thread_id=thread_id, + media_files=media_files, + caption=_dc_caption, + ) + if isinstance(result, dict) and result.get("error"): + return result + return result last_result = None for i, chunk in enumerate(chunks): is_last = (i == len(chunks) - 1) @@ -916,7 +992,30 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, _wa_entry = _pr_wa.get("whatsapp") if _wa_entry is None or _wa_entry.standalone_sender_fn is None: return {"error": "WhatsApp plugin not registered or missing standalone_sender_fn"} + # MEDIA: caption: a single captionable file + short text rides + # as the media's native caption instead of a separate message before + # the bubble (single enforced decision in _media_caption_split). Cap on + # the platform's own message limit so the caption is always deliverable. + _wa_caption, _ = _media_caption_split( + message, media_files, + max_caption_len=(max_len or _DEFAULT_CAPTION_LIMIT), + ) last_result = None + if _wa_caption is not None: + # Single-file captioned send: no separate text chunk, caption on + # the media itself. + result = await _wa_entry.standalone_sender_fn( + pconfig, + chat_id, + "", + media_files=media_files, + thread_id=thread_id, + force_document=force_document, + caption=_wa_caption, + ) + if isinstance(result, dict) and result.get("error"): + return result + return result for i, chunk in enumerate(chunks): is_last = (i == len(chunks) - 1) result = await _wa_entry.standalone_sender_fn( @@ -1109,6 +1208,23 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No last_msg = None warnings = [] + # MEDIA: caption: when a single captionable file is accompanied + # by short text, attach the text to the media bubble as its native + # caption instead of sending it as a separate message beforehand + # (single enforced decision in _media_caption_split). Caption with the + # *formatted* text so MarkdownV2/HTML styling is preserved, but guard + # the formatted length against Telegram's 1024 cap — formatting can + # inflate a raw-<1024 string past it, in which case fall back to a + # separate body message. + _tg_caption = None + from gateway.platforms.base import utf16_len as _utf16_len + _cap, _ = _media_caption_split( + message, media_files, max_caption_len=_TELEGRAM_CAPTION_LIMIT + ) + if _cap is not None and _utf16_len(formatted) <= _TELEGRAM_CAPTION_LIMIT: + _tg_caption = formatted + formatted = "" # suppress the separate text send below + if formatted.strip(): # Chunk *after* formatting: MarkdownV2/HTML escaping inflates the # text (each escaped char like `!`/`.`/`-` becomes `\!`/`\.`/`\-`), @@ -1170,12 +1286,34 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No warning = f"Media file not found, skipping: {media_path}" logger.warning(warning) warnings.append(warning) + # Caption mode suppressed the separate text send; if the file + # it was meant to caption is gone, deliver the caption text on + # its own so the words aren't silently lost. + if _tg_caption is not None and last_msg is None: + try: + last_msg = await _send_telegram_message_with_retry( + bot, chat_id=int_chat_id, text=_tg_caption, + parse_mode=send_parse_mode, **text_kwargs + ) + _tg_caption = None # delivered — don't re-caption a later file + except Exception as _cap_err: + logger.warning( + "Telegram caption-fallback send failed for missing media: %s", + _sanitize_error_text(_cap_err), + ) continue ext = os.path.splitext(media_path)[1].lower() try: with open(media_path, "rb") as f: media_kwargs = dict(thread_kwargs) + # Attach the MEDIA: caption to the bubble itself for + # captionable kinds (photo/video/document). _tg_caption is + # only set for a single captionable file, so this never + # double-captions a multi-file send or a voice note. + if _tg_caption is not None and not (ext in _VOICE_EXTS and is_voice): + media_kwargs["caption"] = _tg_caption + media_kwargs["parse_mode"] = send_parse_mode try: if ext in _IMAGE_EXTS and not force_document: last_msg = await bot.send_photo( @@ -1228,6 +1366,37 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No last_msg = await bot.send_document( chat_id=int_chat_id, document=f, **media_kwargs ) + elif media_kwargs.get("parse_mode") and ( + "parse" in str(media_err).lower() + or "caption" in str(media_err).lower() + ): + # Caption failed to parse as MarkdownV2/HTML — + # retry with a plain-text caption so the media + # (and its caption) still deliver. + logger.warning( + "Caption parse failed for media send, retrying plain: %s", + _sanitize_error_text(media_err), + ) + f.seek(0) + media_kwargs.pop("parse_mode", None) + if not _has_html and media_kwargs.get("caption"): + try: + from plugins.platforms.telegram.adapter import _strip_mdv2 + media_kwargs["caption"] = _strip_mdv2(media_kwargs["caption"]) + except Exception: + pass + if ext in _IMAGE_EXTS and not force_document: + last_msg = await bot.send_photo( + chat_id=int_chat_id, photo=f, **media_kwargs + ) + elif ext in _VIDEO_EXTS: + last_msg = await bot.send_video( + chat_id=int_chat_id, video=f, **media_kwargs + ) + else: + last_msg = await bot.send_document( + chat_id=int_chat_id, document=f, **media_kwargs + ) else: raise except Exception as e: diff --git a/tools/skills_guard.py b/tools/skills_guard.py index a1a8606d6d4..47f200ab880 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -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]}" # --------------------------------------------------------------------------- diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 9883b720ee0..755b32fcbbe 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -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( diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 86a8a5de6cb..a5613f62c4c 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -68,6 +68,7 @@ Usage: import json import logging +import time from hermes_constants import get_hermes_home, display_hermes_home import os @@ -86,6 +87,54 @@ from agent.skill_utils import ( logger = logging.getLogger(__name__) +# Per-session skill discovery cache. _find_all_skills() re-reads every +# SKILL.md on every call; with hundreds of skills this is wasteful. +# Cache validation (mirrors hermes_cli/profiles.py::_count_skills, d5eee133e): +# - signature = per-dir max mtime of the dir AND its immediate children +# (one scandir per dir; catches skill add/remove inside categories, +# which does NOT bump the root dir's mtime), plus the disabled-set +# (config-driven — changes with no filesystem mtime bump at all) +# - a short TTL bounds staleness from in-place SKILL.md edits, which +# bump only the file's mtime, invisible to any directory signature. +# skip_disabled True/False are cached separately. +_SKILLS_CACHE: dict = {} # {cache_key: (signature, timestamp, skills_list)} +_SKILLS_CACHE_TTL_SECONDS = 30.0 +_SKILLS_CACHE_KEY_DISABLED = "with_disabled" +_SKILLS_CACHE_KEY_FILTERED = "filtered" + + +def _skills_scan_signature(dirs_to_scan, disabled) -> tuple: + """Cheap change-signature for the skill scan inputs. + + O(#dirs + #categories) stat calls, not a recursive walk. Includes the + platform the scan's ``skill_matches_platform`` filter will use (read + from ``agent.skill_utils``'s ``sys`` so test patches of that module + are honored) — the scan result is platform-dependent. + """ + from agent import skill_utils as _skill_utils + + platform = getattr(getattr(_skill_utils, "sys", None), "platform", "") + sig = [] + for d in dirs_to_scan: + try: + m = d.stat().st_mtime + except OSError: + continue + try: + with os.scandir(d) as it: + for entry in it: + try: + if entry.is_dir(follow_symlinks=False): + em = entry.stat(follow_symlinks=False).st_mtime + if em > m: + m = em + except OSError: + continue + except OSError: + pass + sig.append((str(d), m)) + return (tuple(sig), frozenset(disabled), platform) + # All skills live in ~/.hermes/skills/ (seeded from bundled skills/ on install). # This is the single source of truth -- agent edits, hub installs, and bundled @@ -627,22 +676,47 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: Returns: List of skill metadata dicts (name, description, category). + + Results are cached per-session; the cache is invalidated when the scan + signature changes (dir/category mtimes or the disabled-set) and expires + after a short TTL to bound staleness from in-place SKILL.md edits. """ from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files - skills = [] - seen_names: set = set() + cache_key = _SKILLS_CACHE_KEY_DISABLED if skip_disabled else _SKILLS_CACHE_KEY_FILTERED - # Load disabled set once (not per-skill) + # Load disabled set once (not per-skill). Part of the cache signature: + # disabling a skill is a config change with no filesystem mtime bump. disabled = set() if skip_disabled else _get_disabled_skill_names() - # Scan local dir first, then external dirs (local takes precedence) - dirs_to_scan = [] + # Collect directories to scan — same resolution as the scan loop below + # (_skills_dir() resolves the LIVE profile HERMES_HOME; the module-level + # SKILLS_DIR can be stale in long-lived runtimes). + dirs_to_scan: list = [] active_skills_dir = _skills_dir() if active_skills_dir.exists(): dirs_to_scan.append(active_skills_dir) dirs_to_scan.extend(get_external_skills_dirs()) + signature = _skills_scan_signature(dirs_to_scan, disabled) + now = time.monotonic() + + cached = _SKILLS_CACHE.get(cache_key) + if ( + cached is not None + and cached[0] == signature + and (now - cached[1]) < _SKILLS_CACHE_TTL_SECONDS + ): + # Per-call shallow copies: callers mutate the returned dicts + # (e.g. web_server annotates s["enabled"]/s["usage"]) — handing + # out the cached objects would poison the cache for everyone else. + return [dict(s) for s in cached[2]] + + skills = [] + seen_names: set = set() + + # Scan local dir first, then external dirs (local takes precedence) — + # dirs_to_scan already resolved above for the signature. for scan_dir in dirs_to_scan: for skill_md in iter_skill_index_files(scan_dir, "SKILL.md"): if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts): @@ -695,7 +769,12 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: ) continue - return skills + # Store in cache keyed by the scan signature computed BEFORE the scan + # (a write racing the scan changes the signature, so the next call + # re-scans rather than serving the torn result past the TTL). Same + # shallow-copy contract as the hit path — the caller may mutate. + _SKILLS_CACHE[cache_key] = (signature, now, skills) + return [dict(s) for s in skills] def _sort_skills(skills: List[Dict[str, Any]]) -> List[Dict[str, Any]]: diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 44ef03af788..fc13367eb11 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -2295,6 +2295,8 @@ def terminal_tool( "command": approval.get("command", command), "description": approval.get("description", "command flagged"), "pattern_key": approval.get("pattern_key", ""), + "smart_denied": approval.get("smart_denied", False), + "allow_permanent": approval.get("allow_permanent", True), }, ensure_ascii=False) # Command was blocked desc = approval.get("description", "command flagged") diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 49f8cbaca22..39e92261a19 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -99,6 +99,7 @@ GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1") OPENAI_BASE_URL = os.getenv("STT_OPENAI_BASE_URL", "https://api.openai.com/v1") XAI_STT_BASE_URL = os.getenv("XAI_STT_BASE_URL", "https://api.x.ai/v1") ELEVENLABS_STT_BASE_URL = os.getenv("ELEVENLABS_STT_BASE_URL", "https://api.elevenlabs.io/v1") +# DeepInfra STT base URL now resolved via hermes_cli.models.deepinfra_base_url (shared). SUPPORTED_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".aac", ".flac"} LOCAL_NATIVE_AUDIO_FORMATS = {".wav", ".aiff", ".aif"} @@ -122,7 +123,7 @@ def _load_stt_config() -> dict: """Load the ``stt`` section from user config, falling back to defaults.""" try: from hermes_cli.config import load_config - return load_config().get("stt", {}) + return load_config().get("stt") or {} except Exception: return {} @@ -229,7 +230,7 @@ def _try_lazy_install_stt() -> bool: return False -# Names of the 6 STT providers with native handlers in this module. +# Names of the STT providers with native handlers in this module. # Kept in sync with ``agent.transcription_registry._BUILTIN_NAMES`` — # a regression test fails if they drift. The plugin hook from # issue #30398-style follow-up rejects plugins registering under any @@ -242,6 +243,8 @@ BUILTIN_STT_PROVIDERS = frozenset({ "openai", "mistral", "xai", + "elevenlabs", + "deepinfra", }) @@ -827,11 +830,24 @@ def _get_provider(stt_config: dict) -> str: ) return "none" + if provider == "deepinfra": + if _HAS_OPENAI and (get_env_value("DEEPINFRA_API_KEY") or "").strip(): + return "deepinfra" + logger.warning( + "STT provider 'deepinfra' configured but DEEPINFRA_API_KEY not set " + "(or openai package missing)" + ) + return "none" + return provider # Unknown — let it fail downstream - # --- Auto-detect (no explicit provider): local > groq > openai > xai > elevenlabs - - # mistral is intentionally skipped while `mistralai` is quarantined on - # PyPI (malicious 2.4.6 release on 2026-05-12). + # --- Auto-detect (no explicit provider): + # local > groq > openai > mistral > xai > elevenlabs > deepinfra --- + # DeepInfra is tried LAST so adding DEEPINFRA_API_KEY (commonly set for the + # chat surface) never silently displaces an existing xAI/ElevenLabs STT + # auto-selection; a DeepInfra-only box still resolves to it. mistral is + # intentionally skipped while `mistralai` is quarantined on PyPI (malicious + # 2.4.6 release on 2026-05-12). if _HAS_FASTER_WHISPER: return "local" @@ -863,6 +879,9 @@ def _get_provider(stt_config: dict) -> str: if get_env_value("ELEVENLABS_API_KEY"): logger.info("No local STT available, using ElevenLabs Scribe STT API") return "elevenlabs" + if _HAS_OPENAI and (get_env_value("DEEPINFRA_API_KEY") or "").strip(): + logger.info("No local STT available, using DeepInfra Whisper API") + return "deepinfra" return "none" @@ -1130,7 +1149,7 @@ def _transcribe_local(file_path: str, model_name: str) -> Dict[str, Any]: # Language: config.yaml (stt.local.language) > env var > auto-detect. _forced_lang = ( - _load_stt_config().get("local", {}).get("language") + (_load_stt_config().get("local") or {}).get("language") or os.getenv(LOCAL_STT_LANGUAGE_ENV) or None ) @@ -1213,7 +1232,7 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any] # Language: config.yaml (stt.local.language) > env var > "en" default. language = ( - _load_stt_config().get("local", {}).get("language") + (_load_stt_config().get("local") or {}).get("language") or os.getenv(LOCAL_STT_LANGUAGE_ENV) or DEFAULT_LOCAL_STT_LANGUAGE ) @@ -1327,22 +1346,36 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]: # --------------------------------------------------------------------------- -def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]: - """Transcribe using OpenAI Whisper API (paid).""" - try: - api_key, base_url = _resolve_openai_audio_client_config() - except ValueError as exc: - return { - "success": False, - "transcript": "", - "error": str(exc), - } +def _transcribe_openai( + file_path: str, + model_name: str, + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + provider_label: str = "openai", +) -> Dict[str, Any]: + """Transcribe via the OpenAI ``audio.transcriptions.create`` SDK shape. + + Also serves as the shared backend for every OpenAI-compatible STT + endpoint (DeepInfra etc.) — callers pass an explicit ``api_key`` / + ``base_url`` to skip the OpenAI-only auth chain, and a + ``provider_label`` so the response carries the right ``provider`` + name. + """ + if api_key is None: + try: + api_key, fallback_base = _resolve_openai_audio_client_config() + except ValueError as exc: + return {"success": False, "transcript": "", "error": str(exc)} + base_url = base_url or fallback_base if not _HAS_OPENAI: return {"success": False, "transcript": "", "error": "openai package not installed"} - # Auto-correct model if caller passed a Groq-only model - if model_name in GROQ_MODELS: + # Auto-correct model if caller passed a Groq-only model. Only applies + # to the native OpenAI path — third-party endpoints may legitimately + # serve a whisper-large-v3 variant. + if provider_label == "openai" and model_name in GROQ_MODELS: logger.info("Model %s not available on OpenAI, using %s", model_name, DEFAULT_STT_MODEL) model_name = DEFAULT_STT_MODEL @@ -1358,10 +1391,12 @@ def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]: ) transcript_text = _extract_transcript_text(transcription) - logger.info("Transcribed %s via OpenAI API (%s, %d chars)", - Path(file_path).name, model_name, len(transcript_text)) + logger.info( + "Transcribed %s via %s (%s, %d chars)", + Path(file_path).name, provider_label, model_name, len(transcript_text), + ) - return {"success": True, "transcript": transcript_text, "provider": "openai"} + return {"success": True, "transcript": transcript_text, "provider": provider_label} finally: close = getattr(client, "close", None) if callable(close): @@ -1376,7 +1411,7 @@ def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]: except APIError as e: return {"success": False, "transcript": "", "error": f"API error: {e}"} except Exception as e: - logger.error("OpenAI transcription failed: %s", e, exc_info=True) + logger.error("%s transcription failed: %s", provider_label, e, exc_info=True) return {"success": False, "transcript": "", "error": f"Transcription failed: {e}"} # --------------------------------------------------------------------------- @@ -1447,7 +1482,7 @@ def _transcribe_xai(file_path: str, model_name: str) -> Dict[str, Any]: } stt_config = _load_stt_config() - xai_config = stt_config.get("xai", {}) + xai_config = stt_config.get("xai") or {} base_url = str( xai_config.get("base_url") or get_env_value("XAI_STT_BASE_URL") @@ -1542,7 +1577,7 @@ def _transcribe_elevenlabs(file_path: str, model_name: str) -> Dict[str, Any]: return {"success": False, "transcript": "", "error": "ELEVENLABS_API_KEY not set"} stt_config = _load_stt_config() - elevenlabs_config = stt_config.get("elevenlabs", {}) + elevenlabs_config = stt_config.get("elevenlabs") or {} base_url = str( elevenlabs_config.get("base_url") or get_env_value("ELEVENLABS_STT_BASE_URL") @@ -1616,6 +1651,59 @@ def _transcribe_elevenlabs(file_path: str, model_name: str) -> Dict[str, Any]: return {"success": False, "transcript": "", "error": f"ElevenLabs STT transcription failed: {e}"} +# --------------------------------------------------------------------------- +# Provider: DeepInfra (OpenAI-compatible /v1/audio/transcriptions) +# --------------------------------------------------------------------------- + + +def _transcribe_deepinfra(file_path: str, model_name: str) -> Dict[str, Any]: + """Resolve DeepInfra credentials/model, then delegate to the OpenAI handler. + + DeepInfra's STT endpoint is OpenAI-compatible, so the actual SDK + call lives in :func:`_transcribe_openai` — this wrapper only owns + DeepInfra-specific credential and model resolution, using the shared + ``hermes_cli.models`` helpers so every DeepInfra surface resolves the + base URL and model ids identically. + """ + api_key = (get_env_value("DEEPINFRA_API_KEY") or "").strip() + if not api_key: + return {"success": False, "transcript": "", "error": "DEEPINFRA_API_KEY not set"} + + from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids + + stt_config = _load_stt_config() + # ``stt.deepinfra: null`` in YAML yields None, not {} — coalesce so the + # ``.get`` calls don't raise (no stt.deepinfra block in DEFAULT_CONFIG to + # deep-merge over the null). + di_config = stt_config.get("deepinfra") if isinstance(stt_config, dict) else None + if not isinstance(di_config, dict): + di_config = {} + base_url = deepinfra_base_url(di_config) + + if not model_name: + candidates = deepinfra_model_ids("stt") + if not candidates: + return { + "success": False, + "transcript": "", + "error": ( + "No DeepInfra STT model available. Pin one in " + "config.yaml under stt.deepinfra.model, or check " + "connectivity to api.deepinfra.com so the live catalog " + "can be fetched." + ), + } + model_name = candidates[0] + + return _transcribe_openai( + file_path, + model_name, + api_key=api_key, + base_url=base_url, + provider_label="deepinfra", + ) + + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -1657,14 +1745,14 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A provider = _get_provider(stt_config) if provider == "local": - local_cfg = stt_config.get("local", {}) + local_cfg = stt_config.get("local") or {} model_name = _normalize_local_model( model or local_cfg.get("model", DEFAULT_LOCAL_MODEL) ) return _transcribe_local(file_path, model_name) if provider == "local_command": - local_cfg = stt_config.get("local", {}) + local_cfg = stt_config.get("local") or {} model_name = _normalize_local_command_model( model or local_cfg.get("model", DEFAULT_LOCAL_MODEL) ) @@ -1675,12 +1763,12 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A return _transcribe_groq(file_path, model_name) if provider == "openai": - openai_cfg = stt_config.get("openai", {}) + openai_cfg = stt_config.get("openai") or {} model_name = model or openai_cfg.get("model", DEFAULT_STT_MODEL) return _transcribe_openai(file_path, model_name) if provider == "mistral": - mistral_cfg = stt_config.get("mistral", {}) + mistral_cfg = stt_config.get("mistral") or {} model_name = model or mistral_cfg.get("model", DEFAULT_MISTRAL_STT_MODEL) return _transcribe_mistral(file_path, model_name) @@ -1690,10 +1778,16 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A return _transcribe_xai(file_path, model_name) if provider == "elevenlabs": - elevenlabs_cfg = stt_config.get("elevenlabs", {}) + elevenlabs_cfg = stt_config.get("elevenlabs") or {} model_name = model or elevenlabs_cfg.get("model_id", DEFAULT_ELEVENLABS_STT_MODEL) return _transcribe_elevenlabs(file_path, model_name) + if provider == "deepinfra": + di_config = stt_config.get("deepinfra") # may be None (YAML null) + di_config = di_config if isinstance(di_config, dict) else {} + model_name = model or di_config.get("model") or "" + return _transcribe_deepinfra(file_path, model_name) + # User-declared command-type provider # (``stt.providers.: type: command``). Fires after the built-in # elif chain — built-in names short-circuit upstream so a user's @@ -1754,7 +1848,7 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A def _resolve_openai_audio_client_config() -> tuple[str, str]: """Return direct OpenAI audio config or a managed gateway fallback.""" stt_config = _load_stt_config() - openai_cfg = stt_config.get("openai", {}) + openai_cfg = stt_config.get("openai") or {} cfg_api_key = openai_cfg.get("api_key", "") cfg_base_url = openai_cfg.get("base_url", "") if cfg_api_key: diff --git a/tools/tts_tool.py b/tools/tts_tool.py index e2a96fb4ad7..7d571e66b1a 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -205,6 +205,8 @@ DEFAULT_GEMINI_TTS_VOICE = "Kore" DEFAULT_GEMINI_TTS_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" DEFAULT_GEMINI_AUDIO_TAGS = False GEMINI_AUDIO_TAG_REWRITE_TASK = "tts_audio_tags" +# Base URL now resolved via hermes_cli.models.deepinfra_base_url (shared). +DEFAULT_DEEPINFRA_TTS_VOICE = "default" # PCM output specs for Gemini TTS (fixed by the API) GEMINI_TTS_SAMPLE_RATE = 24000 GEMINI_TTS_CHANNELS = 1 @@ -341,7 +343,7 @@ def _load_tts_config() -> Dict[str, Any]: try: from hermes_cli.config import load_config config = load_config() - return config.get("tts", {}) + return config.get("tts") or {} except ImportError: logger.debug("hermes_cli.config not available, using default TTS config") return {} @@ -351,7 +353,12 @@ def _load_tts_config() -> Dict[str, Any]: def _get_provider(tts_config: Dict[str, Any]) -> str: - """Get the configured TTS provider name.""" + """Get the explicitly configured TTS provider or the free default. + + Inference credentials do not imply consent to paid speech generation. + Users opt into cloud TTS by setting ``tts.provider`` (normally through + ``hermes tools``); otherwise the historical Edge backend remains active. + """ return (tts_config.get("provider") or DEFAULT_PROVIDER).lower().strip() @@ -397,6 +404,7 @@ BUILTIN_TTS_PROVIDERS = frozenset({ "neutts", "kittentts", "piper", + "deepinfra", }) DEFAULT_COMMAND_TTS_TIMEOUT_SECONDS = 120 @@ -949,7 +957,7 @@ async def _generate_edge_tts(text: str, output_path: str, tts_config: Dict[str, Path to the saved audio file. """ _edge_tts = _import_edge_tts() - edge_config = tts_config.get("edge", {}) + edge_config = tts_config.get("edge") or {} voice = edge_config.get("voice", DEFAULT_EDGE_VOICE) speed = float(edge_config.get("speed", tts_config.get("speed", 1.0))) @@ -982,7 +990,7 @@ def _generate_elevenlabs(text: str, output_path: str, tts_config: Dict[str, Any] if not api_key: raise ValueError("ELEVENLABS_API_KEY not set. Get one at https://elevenlabs.io/") - el_config = tts_config.get("elevenlabs", {}) + el_config = tts_config.get("elevenlabs") or {} voice_id = el_config.get("voice_id", DEFAULT_ELEVENLABS_VOICE_ID) model_id = el_config.get("model_id", DEFAULT_ELEVENLABS_MODEL_ID) @@ -1009,36 +1017,92 @@ def _generate_elevenlabs(text: str, output_path: str, tts_config: Dict[str, Any] return output_path +def _tts_response_format_from_path(output_path: str) -> str: + """Pick an OpenAI-compatible TTS response format from the output extension.""" + if output_path.endswith(".ogg"): + return "opus" + if output_path.endswith(".wav"): + return "wav" + if output_path.endswith(".flac"): + return "flac" + return "mp3" + + # =========================================================================== -# Provider: OpenAI TTS +# Provider: OpenAI TTS (also used by every OpenAI-compatible TTS endpoint — +# DeepInfra delegates here via _generate_deepinfra_tts). # =========================================================================== -def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str: - """ - Generate audio using OpenAI TTS. +def _generate_openai_tts( + text: str, + output_path: str, + tts_config: Dict[str, Any], + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + model: Optional[str] = None, + voice: Optional[str] = None, + speed: Optional[float] = None, +) -> str: + """Generate audio via the OpenAI ``audio.speech.create`` SDK shape. + + Optional kwargs let OpenAI-compatible backends (DeepInfra etc.) reuse + this function — they resolve credentials/model themselves and pass + them through, skipping the OpenAI-only ``_resolve_openai_audio_client_config``. Args: text: Text to convert. output_path: Where to save the audio file. - tts_config: TTS config dict. + tts_config: TTS config dict (used for ``tts.openai`` sub-block + and the global ``speed`` default). + api_key: Bearer token. When None, resolved from the OpenAI auth + chain (config → env → managed gateway). + base_url: API base URL. When None, falls back to + ``tts.openai.base_url`` then the OpenAI default. + model: Model id. When None, reads ``tts.openai.model``. + voice: Voice id. When None, reads ``tts.openai.voice``. + speed: Playback speed. When None, reads ``tts.openai.speed`` / + ``tts.speed``. Returns: Path to the saved audio file. """ - api_key, base_url, is_managed = _resolve_openai_audio_client_config() + # Only resolve the OpenAI auth chain when the caller didn't pass explicit + # credentials. OpenAI-compatible backends (DeepInfra) pass api_key / + # base_url / model / voice through and never hit the managed-gateway path. + fallback_base: Optional[str] = None + is_managed = False + explicit_base_url = base_url is not None + if api_key is None: + api_key, fallback_base, is_managed = _resolve_openai_audio_client_config() - oai_config = tts_config.get("openai", {}) - model = oai_config.get("model", DEFAULT_OPENAI_MODEL) - voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE) - custom_base_url = oai_config.get("base_url") - if custom_base_url: - base_url = custom_base_url - speed = float(oai_config.get("speed", tts_config.get("speed", 1.0))) + # ``tts.openai: null`` in YAML yields None — coalesce so .get() is safe. + oai_config = (tts_config.get("openai") if isinstance(tts_config, dict) else None) or {} + if model is None: + model = oai_config.get("model", DEFAULT_OPENAI_MODEL) + if voice is None: + voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE) + config_base_url = oai_config.get("base_url") + if base_url is None: + # Config override wins over the auth-chain fallback (restores the + # pre-refactor precedence, where tts.openai.base_url beat the resolved + # default); the auth-chain value is the last-resort default. An + # explicit base_url arg from an OpenAI-compatible caller (DeepInfra) + # skips this block entirely and always wins. + base_url = config_base_url or fallback_base or DEFAULT_OPENAI_BASE_URL + if speed is None: + speed_default = tts_config.get("speed", 1.0) if isinstance(tts_config, dict) else 1.0 + speed = float(oai_config.get("speed", speed_default)) # The managed OpenAI audio gateway only proxies MANAGED_OPENAI_TTS_MODELS. # A model set for direct OpenAI (e.g. "tts-1-hd") 400s there with # "Unsupported managed OpenAI speech model", so coerce it — unless the user # redirected base_url to their own endpoint, in which case respect it. - if is_managed and not custom_base_url and model not in MANAGED_OPENAI_TTS_MODELS: + if ( + is_managed + and not explicit_base_url + and not config_base_url + and model not in MANAGED_OPENAI_TTS_MODELS + ): logger.warning( "TTS: managed OpenAI audio gateway does not support model %r; " "falling back to %s. Set VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY " @@ -1047,16 +1111,12 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] ) model = DEFAULT_OPENAI_MODEL - # Determine response format from extension - if output_path.endswith(".ogg"): - response_format = "opus" - else: - response_format = "mp3" + response_format = _tts_response_format_from_path(output_path) OpenAIClient = _import_openai_client() client = OpenAIClient(api_key=api_key, base_url=base_url) try: - create_kwargs = { + create_kwargs: Dict[str, Any] = { "model": model, "voice": voice, "input": text, @@ -1075,6 +1135,64 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] close() +# =========================================================================== +# Provider: DeepInfra TTS +# =========================================================================== +# +# DeepInfra serves TTS over an OpenAI-compatible /v1/openai/audio/speech +# endpoint. Models are discovered live via the shared catalog helper +# (filtered by the ``tts`` surface tag) — no hardcoded model ids in this +# file, so retired models disappear from hermes the next time the +# catalog is fetched without a patch. + + +def _generate_deepinfra_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str: + """Resolve DeepInfra credentials/model, then delegate to the OpenAI handler. + + DeepInfra's audio endpoint is OpenAI-compatible, so there's no need + to duplicate the SDK call — we just pass an explicit api_key / + base_url / model / voice through. Model ids and the base URL come from + the shared ``hermes_cli.models`` helpers so every DeepInfra surface + resolves them identically. + """ + api_key = (get_env_value("DEEPINFRA_API_KEY") or "").strip() + if not api_key: + raise ValueError( + "DEEPINFRA_API_KEY not set. Run `hermes setup` to configure, " + "or set the env var directly." + ) + + # ``tts.deepinfra: null`` in YAML yields None, not {} — coalesce so the + # ``.get`` calls below don't raise AttributeError (there is no + # tts.deepinfra block in DEFAULT_CONFIG to deep-merge over the null). + di_config = tts_config.get("deepinfra") if isinstance(tts_config, dict) else None + if not isinstance(di_config, dict): + di_config = {} + + from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids + + model = di_config.get("model") + if not isinstance(model, str) or not model.strip(): + candidates = deepinfra_model_ids("tts") + if not candidates: + raise ValueError( + "No DeepInfra TTS model available. Pin one in config.yaml " + "under tts.deepinfra.model, or check connectivity to " + "api.deepinfra.com so the live catalog can be fetched." + ) + model = candidates[0] + return _generate_openai_tts( + text, + output_path, + tts_config, + api_key=api_key, + base_url=deepinfra_base_url(di_config), + model=model, + voice=di_config.get("voice", DEFAULT_DEEPINFRA_TTS_VOICE), + speed=float(di_config.get("speed", tts_config.get("speed", 1.0))), + ) + + # =========================================================================== # Provider: xAI TTS # =========================================================================== @@ -1204,7 +1322,7 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - if not api_key: raise ValueError("No xAI credentials found. Configure xAI OAuth in `hermes model` or set XAI_API_KEY.") - xai_config = tts_config.get("xai", {}) + xai_config = tts_config.get("xai") or {} voice_id = str(xai_config.get("voice_id", DEFAULT_XAI_VOICE_ID)).strip() or DEFAULT_XAI_VOICE_ID language = str(xai_config.get("language", DEFAULT_XAI_LANGUAGE)).strip() or DEFAULT_XAI_LANGUAGE sample_rate = int(xai_config.get("sample_rate", DEFAULT_XAI_SAMPLE_RATE)) @@ -1443,7 +1561,7 @@ def _generate_mistral_tts(text: str, output_path: str, tts_config: Dict[str, Any if not api_key: raise ValueError("MISTRAL_API_KEY not set. Get one at https://console.mistral.ai/") - mi_config = tts_config.get("mistral", {}) + mi_config = tts_config.get("mistral") or {} model = mi_config.get("model", DEFAULT_MISTRAL_TTS_MODEL) voice_id = mi_config.get("voice_id") or DEFAULT_MISTRAL_TTS_VOICE_ID @@ -1694,7 +1812,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any] "GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey" ) - raw_gemini_config = tts_config.get("gemini", {}) + raw_gemini_config = tts_config.get("gemini") or {} gemini_config = raw_gemini_config if isinstance(raw_gemini_config, dict) else {} model = str(gemini_config.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL voice = str(gemini_config.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE @@ -1858,7 +1976,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) -> """ import sys - neutts_config = tts_config.get("neutts", {}) + neutts_config = tts_config.get("neutts") or {} ref_audio = neutts_config.get("ref_audio", "") or _default_neutts_ref_audio() ref_text = neutts_config.get("ref_text", "") or _default_neutts_ref_text() model = neutts_config.get("model", "neuphonic/neutts-air-q4-gguf") @@ -1996,7 +2114,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) PiperVoice = _import_piper() import wave - piper_config = tts_config.get("piper", {}) if isinstance(tts_config, dict) else {} + piper_config = tts_config.get("piper") or {} if isinstance(tts_config, dict) else {} voice_name = piper_config.get("voice") or DEFAULT_PIPER_VOICE download_dir = Path(piper_config.get("voices_dir") or _get_piper_voices_dir()).expanduser() download_dir.mkdir(parents=True, exist_ok=True) @@ -2294,6 +2412,17 @@ def text_to_speech_tool( logger.info("Generating speech with OpenAI TTS...") _generate_openai_tts(text, file_str, tts_config) + elif provider == "deepinfra": + try: + _import_openai_client() + except ImportError: + return json.dumps({ + "success": False, + "error": "DeepInfra TTS uses the 'openai' SDK but it isn't installed." + }, ensure_ascii=False) + logger.info("Generating speech with DeepInfra TTS...") + _generate_deepinfra_tts(text, file_str, tts_config) + elif provider == "minimax": logger.info("Generating speech with MiniMax TTS...") _generate_minimax_tts(text, file_str, tts_config) @@ -2466,60 +2595,75 @@ def text_to_speech_tool( # Requirements check # =========================================================================== def check_tts_requirements() -> bool: + """Return whether the explicitly resolved TTS provider can run. + + Availability must mirror :func:`text_to_speech_tool` dispatch. Unrelated + cloud credentials do not make the default Edge backend usable, and an + explicitly selected backend is checked on its own requirements. """ - Check if at least one TTS provider is available. - - Edge TTS needs no API key and is the default, so if the package - is installed, TTS is available. A user-declared command provider - also satisfies the requirement. - - Returns: - bool: True if at least one provider can work. - """ - # Any configured command provider counts as available. - if _has_any_command_tts_provider(): + tts_config = _load_tts_config() + provider = _get_provider(tts_config) + command_config = _resolve_command_provider_config(provider, tts_config) + if command_config is not None: return True - try: - _import_edge_tts() - return True - except ImportError: - pass - try: - _import_elevenlabs() - if get_env_value("ELEVENLABS_API_KEY"): - return True - except ImportError: - pass - try: - _import_openai_client() - if _has_openai_audio_backend(): - return True - except ImportError: - pass - if get_env_value("MINIMAX_API_KEY"): - return True - try: - from tools.xai_http import resolve_xai_http_credentials - if resolve_xai_http_credentials().get("api_key"): + if provider == "edge": + try: + _import_edge_tts() return True + except ImportError: + return _check_neutts_available() + if provider == "elevenlabs": + try: + _import_elevenlabs() + except ImportError: + return False + return bool(get_env_value("ELEVENLABS_API_KEY")) + if provider == "openai": + try: + _import_openai_client() + except ImportError: + return False + return _has_openai_audio_backend() + if provider == "deepinfra": + try: + _import_openai_client() + except ImportError: + return False + return bool(get_env_value("DEEPINFRA_API_KEY")) + if provider == "minimax": + return bool(get_env_value("MINIMAX_API_KEY")) + if provider == "xai": + try: + from tools.xai_http import resolve_xai_http_credentials + + return bool(resolve_xai_http_credentials().get("api_key")) + except Exception: + return False + if provider == "gemini": + return bool(get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY")) + if provider == "mistral": + try: + _import_mistral_client() + except ImportError: + return False + return bool(get_env_value("MISTRAL_API_KEY")) + if provider == "neutts": + return _check_neutts_available() + if provider == "kittentts": + return _check_kittentts_available() + if provider == "piper": + return _check_piper_available() + + try: + from agent.tts_registry import get_provider + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + plugin = get_provider(provider) + return bool(plugin and plugin.is_available()) except Exception: - pass - if get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY"): - return True - try: - _import_mistral_client() - if get_env_value("MISTRAL_API_KEY"): - return True - except ImportError: - pass - if _check_neutts_available(): - return True - if _check_kittentts_available(): - return True - if _check_piper_available(): - return True - return False + return False def _resolve_openai_audio_client_config() -> tuple[str, str, bool]: @@ -2619,7 +2763,7 @@ def stream_tts_to_speaker( model_id = DEFAULT_ELEVENLABS_STREAMING_MODEL_ID tts_config = _load_tts_config() - el_config = tts_config.get("elevenlabs", {}) + el_config = tts_config.get("elevenlabs") or {} voice_id = el_config.get("voice_id", voice_id) model_id = el_config.get("streaming_model_id", el_config.get("model_id", model_id)) diff --git a/tools/video_generation_tool.py b/tools/video_generation_tool.py index fe20ca0de0b..73e2cd01f45 100644 --- a/tools/video_generation_tool.py +++ b/tools/video_generation_tool.py @@ -472,29 +472,19 @@ def _build_dynamic_video_schema() -> Dict[str, Any]: """ parts: List[str] = [_GENERIC_DESCRIPTION] - configured = _read_configured_video_provider() configured_model = _read_configured_video_model() - if not configured: - parts.append( - "\nNo video backend is configured. Calls will return an error " - "until the user picks one via `hermes tools` → Video Generation." - ) - return {"description": "\n".join(parts)} - - try: - from agent.video_gen_registry import get_provider - from hermes_cli.plugins import _ensure_plugins_discovered - - _ensure_plugins_discovered() - provider = get_provider(configured) - except Exception: - provider = None + # Reflect the *resolved* active provider (same resolution the handler uses + # in _resolve_active_provider): an explicit ``video_gen.provider``, or — + # when unset — the single available registered backend. Keeping the + # description in sync with execution stops the agent from being told + # "no backend configured" while a call would actually succeed. + provider = _resolve_active_provider() if provider is None: parts.append( - f"\nActive backend: {configured} (plugin not yet loaded — the " - f"tool will retry discovery on first call)." + "\nNo video backend is available. Calls will return an error " + "until the user picks one via `hermes tools` → Video Generation." ) return {"description": "\n".join(parts)} @@ -548,7 +538,7 @@ def _build_dynamic_video_schema() -> Dict[str, Any]: max_refs = caps.get("max_reference_images") or 0 if max_refs: parts.append(f"- reference_image_urls: up to {max_refs} images") - if configured == "xai": + if provider.name == "xai": parts.append( "- chaining: for edit/extend pass the public HTTPS MP4 in `video` " "or `public_url` from the prior Imagine result (files-cdn). For " diff --git a/tools/web_tools.py b/tools/web_tools.py index 409b46fa606..7754c386a56 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -103,6 +103,21 @@ import sys logger = logging.getLogger(__name__) +def _web_extract_url(value: Any) -> Optional[str]: + """Return a usable URL from a model-supplied extract item. + + Models sometimes forward a complete web-search result instead of its URL. + Accept the two common URL keys, but reject missing/non-string values rather + than stringifying arbitrary objects into misleading fetch targets. + """ + if isinstance(value, dict): + value = value.get("url") or value.get("href") + if not isinstance(value, str): + return None + value = value.strip() + return value or None + + # ─── Backend Selection ──────────────────────────────────────────────────────── def _env_value(name: str) -> str: @@ -132,7 +147,11 @@ def _load_web_config() -> dict: """Load the ``web:`` section from ~/.hermes/config.yaml.""" try: from hermes_cli.config import load_config - return load_config().get("web", {}) + # ``or {}``: a present-but-null ``web:`` section (YAML ``web:`` with no + # body) makes ``.get("web", {})`` return None, which would break every + # caller that does ``_load_web_config().get(...)``. Honor the ``-> dict`` + # contract so callers never see None. + return load_config().get("web") or {} except (ImportError, Exception): return {} @@ -722,7 +741,7 @@ def web_search_tool(query: str, limit: int = 5) -> str: async def web_extract_tool( - urls: List[str], + urls: List[Any], format: str = None, char_limit: Optional[int] = None, ) -> str: @@ -738,7 +757,8 @@ async def web_extract_tool( ``[IMAGE: alt]`` placeholders (real image URLs are preserved as links). Args: - urls (List[str]): List of URLs to extract content from + urls (List[Any]): URL strings or search-result objects containing a + string ``url`` or ``href`` field format (str): Desired output format ("markdown" or "html", optional) char_limit (Optional[int]): Per-page char budget sent to the model (default: web.extract_char_limit or 15000). Larger pages truncate. @@ -758,7 +778,21 @@ async def web_extract_tool( from agent.redact import _PREFIX_RE from urllib.parse import unquote normalized_urls: List[str] = [] - for _url in urls: + normalized_indices: List[int] = [] + invalid_urls: Dict[int, Dict[str, Any]] = {} + for index, item in enumerate(urls): + _url = _web_extract_url(item) + if _url is None: + invalid_urls[index] = { + "url": "", + "title": "", + "content": "", + "error": ( + f"Invalid URL item at index {index}: expected a URL string " + "or an object with a string 'url' or 'href' field" + ), + } + continue normalized_url = normalize_url_for_request(_url) if ( _PREFIX_RE.search(_url) @@ -783,6 +817,7 @@ async def web_extract_tool( ), }) normalized_urls.append(normalized_url) + normalized_indices.append(index) debug_call_data = { "parameters": { @@ -804,15 +839,17 @@ async def web_extract_tool( # ── SSRF protection — filter out private/internal URLs before any backend ── safe_urls = [] - ssrf_blocked: List[Dict[str, Any]] = [] - for url in normalized_urls: + safe_indices = [] + ssrf_blocked: Dict[int, Dict[str, Any]] = {} + for index, url in zip(normalized_indices, normalized_urls): if not await async_is_safe_url(url): - ssrf_blocked.append({ + ssrf_blocked[index] = { "url": url, "title": "", "content": "", "error": "Blocked: URL targets a private or internal network address", - }) + } else: safe_urls.append(url) + safe_indices.append(index) # Dispatch only safe URLs to the configured backend if not safe_urls: @@ -906,9 +943,25 @@ async def web_extract_tool( provider.extract, safe_urls, format=format ) - # Merge any SSRF-blocked results back in - if ssrf_blocked: - results = ssrf_blocked + results + # Reconstruct the original input order across invalid, blocked, and + # provider-processed entries. Providers are expected to preserve the + # order of the safe URL list they receive. + if invalid_urls or ssrf_blocked: + safe_results = { + index: ( + results[position] + if position < len(results) + else { + "url": safe_urls[position], + "title": "", + "content": "", + "error": "Extract backend returned no result for this URL", + } + ) + for position, index in enumerate(safe_indices) + } + by_index = {**safe_results, **ssrf_blocked, **invalid_urls} + results = [by_index[index] for index in range(len(urls))] response = {"results": results} @@ -1004,7 +1057,9 @@ def check_web_api_key() -> bool: :func:`_is_backend_available`, which delegates non-legacy names to the registry. """ - configured = _load_web_config().get("backend", "").lower().strip() + # ``or ""``: a null ``web.backend`` value yields None from ``.get``, and + # ``None.lower()`` would raise. Mirrors ``_get_backend``. + configured = (_load_web_config().get("backend") or "").lower().strip() if configured and _is_backend_available(configured): return True # Any built-in backend with credentials present. This is a boolean OR, so diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index ded460f2811..3f243153964 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -60,6 +60,42 @@ def _segments(path: str) -> list[str]: return [s for s in re.split(r"[/\\]", (path or "").rstrip("/\\")) if s] +def _is_windows_path(path: str) -> bool: + value = (path or "").strip() + # Drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any backslash-rooted path + # — the root-relative `\wsl.localhost\…` / `\Users\…` spellings included. A + # single leading `/` stays POSIX (case-sensitive). + return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\", "//")) + + +def _comparison_segments(path: str) -> list[str]: + """Path segments suitable for identity comparisons on any host. + + Windows paths remain case-insensitive even when tests or remote backends run + on POSIX. Display paths and emitted IDs keep their original spelling. + """ + segs = _segments(path) + return [segment.casefold() for segment in segs] if _is_windows_path(path) else segs + + +def _path_key(path: str) -> str: + """Canonical comparison key (separator/trailing-slash agnostic).""" + return "/".join(_comparison_segments(path)) + + +def _lane_key(path_or_lane: str) -> str: + """Canonicalize only the path portion of a lane id. + + Branch labels remain byte-preserved; repo/worktree paths follow platform path + identity so equivalent Windows spellings do not create duplicate lanes. + """ + for marker in ("::branch::", "::kanban"): + if marker in path_or_lane: + root, suffix = path_or_lane.split(marker, 1) + return f"{_path_key(root)}{marker}{suffix}" + return _path_key(path_or_lane) + + def base_name(path: str) -> str: segs = _segments(path) return segs[-1] if segs else "" @@ -73,8 +109,8 @@ def kanban_worktree_dir(path: str) -> Optional[str]: def _is_path_under(folder: str, target: str) -> bool: """True when ``target`` equals ``folder`` or is nested under it (segment-wise).""" - f = _segments(folder) - t = _segments(target) + f = _comparison_segments(folder) + t = _comparison_segments(target) if not f or len(f) > len(t): return False return all(f[i] == t[i] for i in range(len(f))) @@ -249,7 +285,8 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool if not placement: continue - entry = lanes.get(placement["lane_key"]) + lane_identity = _lane_key(placement["lane_key"]) + entry = lanes.get(lane_identity) if entry is None: entry = { "group": { @@ -264,7 +301,7 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool "repo_label": placement["repo_label"], "repo_path": placement["repo_path"], } - lanes[placement["lane_key"]] = entry + lanes[lane_identity] = entry entry["group"]["sessions"].append(session) repos: dict[str, dict] = {} @@ -275,7 +312,8 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool if not hydrate: group["sessions"] = [] - repo = repos.get(entry["repo_key"]) + repo_identity = _path_key(entry["repo_key"]) + repo = repos.get(repo_identity) if repo is None: repo = { "id": entry["repo_key"], @@ -284,7 +322,7 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool "groups": [], "sessionCount": 0, } - repos[entry["repo_key"]] = repo + repos[repo_identity] = repo repo["groups"].append(group) repo["sessionCount"] += count @@ -311,7 +349,12 @@ def _seed_folder_repos( empty) project body. Folders already covered by a session-derived repo (same git root) are left untouched. """ - seen = {r["id"] for r in repos} | {r["path"] for r in repos if r.get("path")} + seen = { + _path_key(value) + for repo in repos + for value in (repo.get("id"), repo.get("path")) + if value + } seeded = list(repos) for folder in folders or []: @@ -320,10 +363,11 @@ def _seed_folder_repos( continue info = resolve(raw) if resolve else None root = (info or {}).get("repo_root") or re.sub(r"[/\\]+$", "", raw) - if not root or root in seen: + root_key = _path_key(root) + if not root_key or root_key in seen: continue seeded.append({"id": root, "label": base_name(root) or root, "path": root, "groups": [], "sessionCount": 0}) - seen.add(root) + seen.add(root_key) if len(seeded) != len(repos): _disambiguate_labels(seeded) @@ -347,7 +391,7 @@ class _FolderIndex: self._by_path: dict[str, tuple[dict, int]] = {} for project in projects: for folder in project.get("folders") or []: - segs = _segments(folder.get("path") or "") + segs = _comparison_segments(folder.get("path") or "") if not segs: continue key = "/".join(segs) @@ -359,7 +403,7 @@ class _FolderIndex: def match(self, target: str) -> tuple[Optional[dict], int]: """Owning project for ``target`` by longest ancestor folder, + its depth.""" - segs = _segments(target or "") + segs = _comparison_segments(target or "") # Longest prefix first → deepest (most specific) folder wins. for end in range(len(segs), 0, -1): hit = self._by_path.get("/".join(segs[:end])) @@ -430,6 +474,7 @@ def build_tree( preview_limit: int = 3, hydrate: bool = False, is_junk_root: Optional[Callable[[str], bool]] = None, + is_junk_cwd: Optional[Callable[[str], bool]] = None, ) -> dict: """Build the authoritative project tree. @@ -437,9 +482,11 @@ def build_tree( ``sessions`` are projected session-row dicts (must carry ``id``, ``cwd``, ``git_branch``, ``git_repo_root``, ``started_at``, ``last_active``). ``discovered_repos`` are ``{"root", "label", "sessions", "last_active"}``. - ``is_junk_root`` flags roots that must never become an AUTO project (the - bare home dir, the HERMES_HOME subtree) — their sessions fall through to the - flat Recents list. User-created projects are honored regardless. + ``is_junk_root`` flags git roots that must never become an AUTO project (the + bare home dir, the HERMES_HOME subtree). ``is_junk_cwd`` is the narrower + policy for non-git session folders: selected descendants may be intentional + workspaces even when their parent tree contains Hermes state. User-created + projects are honored regardless. Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When ``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but @@ -448,6 +495,7 @@ def build_tree( """ active_projects = [p for p in projects if not p.get("archived")] _junk = is_junk_root or (lambda _root: False) + _junk_cwd = is_junk_cwd or (lambda _cwd: False) folder_index = _FolderIndex(active_projects) by_project: dict[str, list[dict]] = {} @@ -493,34 +541,67 @@ def build_tree( ) ) - # Tier 2: auto projects from leftover sessions, one per common git repo root. - by_repo: dict[str, list[dict]] = {} + # Tier 2: auto projects from leftover sessions. Prefer the common git repo + # root, then fall back to the session cwd for historical/non-git workspaces. + # The pre-Projects desktop grouped every non-empty cwd; keeping that fallback + # prevents upgrades from flattening those sessions into Recents. + by_auto_root: dict[str, dict] = {} + + def _add_auto(root: str, session: dict) -> None: + key = _path_key(root) + if not key: + return + bucket = by_auto_root.setdefault(key, {"root": root, "sessions": []}) + bucket["sessions"].append(session) + for session in unowned: root = _session_repo_root(session, resolve) if root: - by_repo.setdefault(root, []).append(session) + # A real git root uses the stricter repo policy. Do not reinterpret a + # filtered internal repo as a cwd-only project. + if not _junk(root): + _add_auto(root, session) + continue + + cwd = (session.get("cwd") or "").strip() + if not cwd or _junk_cwd(cwd): + continue + placement = _place( + cwd, + (session.get("git_branch") or "").strip(), + resolve, + (session.get("git_repo_root") or "").strip(), + ) + if placement: + _add_auto(placement["repo_key"], session) seen: set[str] = set() - for repo_root, repo_sessions in by_repo.items(): - # The home dir / HERMES_HOME subtree is config + state, never a project; - # its sessions stay loose in Recents (not scoped to a phantom project). - if _junk(repo_root): - continue - repos = _build_repos(repo_sessions, resolve, hydrate) - repo_node = next((r for r in repos if r["id"] == repo_root or r["path"] == repo_root), None) + for bucket in by_auto_root.values(): + auto_root = bucket["root"] + auto_sessions = bucket["sessions"] + auto_key = _path_key(auto_root) + repos = _build_repos(auto_sessions, resolve, hydrate) + repo_node = next( + ( + repo + for repo in repos + if _path_key(repo.get("id") or repo.get("path") or "") == auto_key + ), + None, + ) if repo_node is None: continue - seen.add(repo_root) - scoped_ids.extend(s["id"] for s in repo_sessions if s.get("id")) + seen.add(auto_key) + scoped_ids.extend(s["id"] for s in auto_sessions if s.get("id")) result.append( _project_node( - pid=repo_root, - label=base_name(repo_root) or repo_root, - path=repo_root, + pid=auto_root, + label=base_name(auto_root) or auto_root, + path=auto_root, repos=repos, session_count=repo_node["sessionCount"], - last_active=_last_active(repo_sessions), - preview_sessions=_previews(repo_sessions), + last_active=_last_active(auto_sessions), + preview_sessions=_previews(auto_sessions), is_auto=True, ) ) @@ -533,9 +614,10 @@ def build_tree( continue info = resolve(raw_root) if resolve else None root = (info or {}).get("repo_root") or raw_root - if root in seen or _junk(root) or _project_for_path(folder_index, root): + root_key = _path_key(root) + if root_key in seen or _junk(root) or _project_for_path(folder_index, root): continue - seen.add(root) + seen.add(root_key) label = repo.get("label") or base_name(root) or root result.append( _project_node( diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8e825a22669..83c79975e2a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -568,24 +568,21 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No history = list(session.get("history", [])) # ── Persist unflushed messages to SQLite ────────────────────────── - # Two sources, tried in order of freshness: - # 1. agent._session_messages — set by the last _persist_session() - # call inside run_conversation(). This is the most recent - # snapshot the agent thread wrote, and may include partial - # turn data that hasn't reached session["history"] yet. - # 2. session["history"] — updated after run_conversation() - # returns. Stale when the agent is mid‑turn, but correct - # when the turn completed before finalize. - # Best‑effort — the agent thread may still be mid‑turn, so only - # previously completed messages are guaranteed. + # Flush ``agent._session_messages`` via ``_persist_session``'s marker-based + # dedup (same contract as the gateway-shutdown flush, #13121). Do NOT pass + # ``conversation_history``: ``session["history"]`` and ``_session_messages`` + # alias the SAME list once a turn completes, so passing it made + # ``_flush_messages_to_session_db`` treat every message as already-durable + # and skip it — a data-loss bug when finalize is the sole persist path after + # a WS disconnect/restart (e.g. the in-turn flush hit a transient SQLite + # failure). Markers persist the genuinely-unflushed tail without duplicating + # durable rows (including a resumed-but-not-run session's already-in-DB + # transcript, which stays in ``session["history"]`` only). if agent is not None and hasattr(agent, "_persist_session"): - snapshot = ( - getattr(agent, "_session_messages", None) - or history - ) + snapshot = getattr(agent, "_session_messages", None) if snapshot: try: - agent._persist_session(snapshot, conversation_history=history) + agent._persist_session(snapshot) except Exception: pass @@ -1155,6 +1152,13 @@ def _emit_approval_request(sid: str, data: dict | None) -> None: platforms and the SSE/API stream fixed in #50767). Reuse the shared gateway seam so all approval transports redact consistently.""" payload = dict(data or {}) + if "choices" not in payload: + if payload.get("smart_denied"): + payload["choices"] = ["once", "deny"] + elif payload.get("allow_permanent") is False: + payload["choices"] = ["once", "session", "deny"] + elif "allow_permanent" in payload: + payload["choices"] = ["once", "session", "always", "deny"] if "command" in payload: from gateway.run import _redact_approval_command @@ -1862,7 +1866,10 @@ def _persist_session_git_meta(session: dict, cwd: str) -> None: def _set_session_cwd(session: dict, cwd: str) -> str: - resolved = os.path.abspath(os.path.expanduser(str(cwd))) + from hermes_constants import translate_cwd_for_wsl_backend + + cwd = translate_cwd_for_wsl_backend(str(cwd)) + resolved = os.path.abspath(os.path.expanduser(cwd)) if not os.path.isdir(resolved): raise ValueError(f"working directory does not exist: {cwd}") session["cwd"] = resolved @@ -2038,15 +2045,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: @@ -2475,6 +2493,19 @@ def _write_config_key(key_path: str, value): _STATUSBAR_MODES = frozenset({"off", "top", "bottom"}) +_APPROVAL_MODES = frozenset({"manual", "smart", "off"}) + + +def _load_approval_mode() -> str: + from hermes_cli.config import DEFAULT_CONFIG, _deep_merge + from tools.approval import _normalize_approval_mode + + raw_cfg = _load_cfg() + cfg = _deep_merge(DEFAULT_CONFIG, raw_cfg if isinstance(raw_cfg, dict) else {}) + approvals = cfg.get("approvals") + raw = approvals.get("mode") if isinstance(approvals, dict) else None + mode = _normalize_approval_mode(raw) + return mode if mode in _APPROVAL_MODES else "manual" def _coerce_statusbar(raw) -> str: @@ -3310,7 +3341,8 @@ def _current_profile_name() -> str: # checkout), surfacing a one-click "update to align" prompt instead of failing # cryptically downstream. Bump whenever the desktop's backend contract changes. # v2: adds the file.attach RPC (remote-gateway non-image file upload). -DESKTOP_BACKEND_CONTRACT = 2 +# v3: adds approvals.mode config RPCs and session.info reconciliation. +DESKTOP_BACKEND_CONTRACT = 3 def _session_info(agent, session: dict | None = None) -> dict: @@ -3344,17 +3376,15 @@ def _session_info(agent, session: dict | None = None) -> dict: # the desktop status bar (it would show YOLO "off" while approvals.mode=off # silently auto-approves every dangerous command). yolo = False + approval_mode = "manual" try: - from tools.approval import ( - _YOLO_MODE_FROZEN, - _get_approval_mode, - is_session_yolo_enabled, - ) + from tools.approval import _YOLO_MODE_FROZEN, is_session_yolo_enabled session_yolo = ( bool(is_session_yolo_enabled(session_key)) if session_key else False ) - yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or _get_approval_mode() == "off" + approval_mode = _load_approval_mode() + yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or approval_mode == "off" except Exception: yolo = False info: dict = { @@ -3364,6 +3394,7 @@ def _session_info(agent, session: dict | None = None) -> dict: "service_tier": service_tier, "fast": service_tier == "priority", "yolo": yolo, + "approval_mode": approval_mode, "tools": {}, "skills": {}, "cwd": cwd, @@ -3669,6 +3700,19 @@ def _on_tool_progress( # the stable tool id and args. Emitting another id-less progress row # here makes the desktop live view diverge from hydrated history. return + if event_type == "tool.output_risk" and name: + metadata = _kwargs.get("risk_metadata") + if not isinstance(metadata, dict): + return + payload: dict[str, object] = { + "tool_id": str(_kwargs.get("tool_call_id") or ""), + "name": str(name), + "risk": str(metadata.get("risk") or "low"), + "findings": [str(item) for item in metadata.get("findings", [])], + "redacted": bool(metadata.get("redacted", False)), + } + _emit("tool.output_risk", sid, payload) + return if event_type == "reasoning.available" and preview: payload: dict[str, object] = {"text": str(preview)} if _session_verbose(sid): @@ -3860,6 +3904,9 @@ def _agent_cbs(sid: str) -> dict: "tool_gen_callback": lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}), "thinking_callback": lambda text: _emit("thinking.delta", sid, {"text": text}), + # Affection reaction (ily / <3 / good bot) → hearts. Core-detected, so + # the TUI heart and desktop floating hearts share one signal. + "reaction_callback": lambda kind: _emit("reaction", sid, {"kind": kind}), "reasoning_callback": lambda text: _emit( "reasoning.delta", sid, @@ -8746,10 +8793,18 @@ def _notification_poller_loop( continue rid = f"__notif__{int(time.time() * 1000)}" + from tools.async_delegation import ( + claim_event_delivery, complete_event_delivery, release_event_delivery, + ) + _claim = claim_event_delivery(evt, "tui-poller") + if _claim is None: + continue try: _emit("message.start", sid) _run_prompt_submit(rid, sid, session, text) + complete_event_delivery(evt, _claim) except Exception as exc: + release_event_delivery(evt, _claim) print( f"[tui_gateway] notification poller dispatch failed: " f"{type(exc).__name__}: {exc}", @@ -8798,10 +8853,18 @@ def _notification_poller_loop( session["running"] = True rid = f"__notif__{int(time.time() * 1000)}" + from tools.async_delegation import ( + claim_event_delivery, complete_event_delivery, release_event_delivery, + ) + _claim = claim_event_delivery(evt, "tui-poller") + if _claim is None: + continue try: _emit("message.start", sid) _run_prompt_submit(rid, sid, session, text) + complete_event_delivery(evt, _claim) except Exception as exc: + release_event_delivery(evt, _claim) print( f"[tui_gateway] notification poller dispatch failed: " f"{type(exc).__name__}: {exc}", @@ -9351,10 +9414,18 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: process_registry.completion_queue.put(_evt) break session["running"] = True + from tools.async_delegation import ( + claim_event_delivery, complete_event_delivery, release_event_delivery, + ) + _claim = claim_event_delivery(_evt, "tui-post-turn") + if _claim is None: + continue try: _emit("message.start", sid) _run_prompt_submit(rid, sid, session, synth) + complete_event_delivery(_evt, _claim) except Exception as _n_exc: + release_event_delivery(_evt, _claim) print( f"[tui_gateway] completion notification dispatch failed: " f"{type(_n_exc).__name__}: {_n_exc}", @@ -10150,11 +10221,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, "") @@ -10175,12 +10248,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") @@ -10370,6 +10443,22 @@ def _(rid, params: dict) -> dict: agent.verbose_logging = nv == "verbose" return _ok(rid, {"key": key, "value": nv}) + if key in {"approval_mode", "approvals.mode"}: + raw = str(value or "").strip().lower() + if raw not in _APPROVAL_MODES: + return _err( + rid, + 4002, + f"unknown approval mode: {value}; pick one of manual|smart|off", + ) + + _write_config_key("approvals.mode", raw) + for sid, sess in list(_sessions.items()): + agent = sess.get("agent") + if agent is not None: + _emit("session.info", sid, _session_info(agent, sess)) + return _ok(rid, {"key": "approvals.mode", "value": raw}) + if key == "yolo": # Approval bypass. Two scopes: # scope="session" (default) — same as the TUI's Shift+Tab. Toggles @@ -10907,6 +10996,25 @@ def _is_repo_junk(root: str) -> bool: return real == home or real == hermes_home or real.startswith(hermes_home + os.sep) +def _is_session_cwd_junk(cwd: str) -> bool: + """A non-git cwd that should stay in flat Recents rather than auto-group. + + Unlike discovered git roots, an explicitly selected descendant of + HERMES_HOME may be an intentional prose/data workspace. The pre-Projects + desktop surfaced every such cwd, so exclude only the two broad defaults + that would create catch-all projects. + """ + if not cwd: + return True + + from hermes_constants import get_hermes_home + + real = os.path.normcase(os.path.realpath(cwd)) + home = os.path.normcase(os.path.realpath(os.path.expanduser("~"))) + hermes_home = os.path.normcase(os.path.realpath(str(get_hermes_home()))) + return real == home or real == hermes_home + + def _discover_repos_payload(db, *, conn=None, backfill: bool = True) -> list[dict]: """Merge filesystem-scanned repos (cached) with session-derived repo roots. @@ -11108,6 +11216,7 @@ def _build_project_tree( preview_limit=preview_limit, hydrate=hydrate, is_junk_root=_is_repo_junk, + is_junk_cwd=_is_session_cwd_junk, ) return tree, active_id @@ -11263,6 +11372,11 @@ def _(rid, params: dict) -> dict: ) if key == "busy": return _ok(rid, {"value": _load_busy_input_mode()}) + if key in {"approval_mode", "approvals.mode"}: + try: + return _ok(rid, {"value": _load_approval_mode()}) + except Exception as e: + return _err(rid, 5001, str(e)) if key == "details_mode": allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) raw = ( @@ -11853,6 +11967,52 @@ def _(rid, params: dict) -> dict: except Exception: pass + try: + from agent.skill_bundles import ( + build_bundle_invocation_message, + get_skill_bundles, + resolve_bundle_command_key, + ) + + from hermes_cli.commands import resolve_command + + bundle_key = ( + resolve_bundle_command_key(name) + if resolve_command(name) is None + else None + ) + except Exception: + bundle_key = None + + if bundle_key is not None: + try: + bundle_result = build_bundle_invocation_message( + bundle_key, + arg, + task_id=session.get("session_key", "") if session else "", + platform=_resolve_session_platform(), + ) + except Exception as exc: + return _err(rid, 4018, f"bundle dispatch failed: {exc}") + + if not bundle_result: + return _err(rid, 4018, f"failed to load bundle: {bundle_key}") + + msg, loaded_names, missing = bundle_result + bundle_info = get_skill_bundles().get(bundle_key, {}) + bundle_name = bundle_info.get("name", bundle_key.lstrip("/")) + notice = f"⚡ Loading bundle: {bundle_name} ({len(loaded_names)} skills)" + if missing: + notice += f"\nSkipped missing skills: {', '.join(missing)}" + return _ok( + rid, + { + "type": "send", + "message": msg, + "notice": notice, + }, + ) + try: from agent.skill_commands import ( scan_skill_commands, @@ -12264,7 +12424,7 @@ def _(rid, params: dict) -> dict: except Exception as exc: return _err(rid, 5009, f"compress failed: {exc}") - return _err(rid, 4018, f"not a quick/plugin/skill command: {name}") + return _err(rid, 4018, f"not a quick/plugin/bundle/skill command: {name}") # ── Methods: paste ──────────────────────────────────────────────────── @@ -13056,10 +13216,11 @@ def _(rid, params: dict) -> dict: if not cmd: return _err(rid, 4004, "empty command") - # Skill slash commands and _pending_input commands must NOT go through the - # slash worker — see _PENDING_INPUT_COMMANDS definition above. Plugin - # commands must also avoid the worker, but unlike skills/pending-input they - # still return normal slash.exec output so the TUI keeps the pager path. + # Skill and bundle slash commands plus _pending_input commands must NOT go + # through the slash worker — see _PENDING_INPUT_COMMANDS definition above. + # Plugin commands must also avoid the worker, but unlike skills and + # pending-input commands they still return normal slash.exec output so the + # TUI keeps the pager path. _cmd_text = cmd.lstrip("/") if cmd.startswith("/") else cmd _cmd_parts = _cmd_text.split(maxsplit=1) _cmd_base = (_cmd_parts[0] if _cmd_parts else "").lower() @@ -13087,6 +13248,27 @@ def _(rid, params: dict) -> dict: "snapshot restore mutates live config/state; use command.dispatch for /snapshot restore", ) + try: + from agent.skill_bundles import resolve_bundle_command_key + from hermes_cli.commands import resolve_command + + _bundle_key = ( + resolve_bundle_command_key(_cmd_base) + if resolve_command(_cmd_base) is None + else None + ) + if _bundle_key is not None: + return _methods["command.dispatch"]( + rid, + { + "name": _bundle_key.lstrip("/"), + "arg": _cmd_arg, + "session_id": params.get("session_id", ""), + }, + ) + except Exception: + pass + try: from agent.skill_commands import get_skill_commands diff --git a/tui_gateway/slash_worker.py b/tui_gateway/slash_worker.py index 00e83bedf14..34cc7595373 100644 --- a/tui_gateway/slash_worker.py +++ b/tui_gateway/slash_worker.py @@ -65,6 +65,27 @@ def _is_orphaned(original_ppid, parent_create_time, getppid=os.getppid) -> bool: return True +def _prepare_slash_worker_runtime() -> None: + """Start bounded MCP discovery before HermesCLI snapshots tools. + + Each slash_worker child is its own process — the parent ``hermes serve`` + discovery thread does not populate this registry (issue #61891). + """ + import logging + + from hermes_cli.mcp_startup import ( + start_background_mcp_discovery, + wait_for_mcp_discovery, + ) + + logger = logging.getLogger(__name__) + start_background_mcp_discovery( + logger=logger, + thread_name="slash-worker-mcp-discovery", + ) + wait_for_mcp_discovery() + + def _start_parent_death_watchdog(original_ppid, parent_create_time) -> None: def _loop(): while not _is_orphaned(original_ppid, parent_create_time): @@ -129,6 +150,7 @@ def main(): except psutil.Error: parent_create_time = 0.0 _start_parent_death_watchdog(orig_ppid, parent_create_time) + _prepare_slash_worker_runtime() with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False) diff --git a/ui-tui/README.md b/ui-tui/README.md index 159db8293b6..fe5ab7c8db1 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -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 -``` \ No newline at end of file +``` diff --git a/ui-tui/package.json b/ui-tui/package.json index d0a59798fb7..7c83ad71e6b 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -4,14 +4,16 @@ "private": true, "type": "module", "scripts": { - "dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx", + "dev": "npm run build:ink && tsx --watch src/entry.tsx", "start": "tsx src/entry.tsx", "build": "node scripts/build.mjs", + "build:ink": "npm run build --prefix packages/hermes-ink", "typecheck": "tsc --noEmit -p tsconfig.json", "lint": "eslint src/ packages/", "lint:fix": "eslint src/ packages/ --fix", "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'", "fix": "npm run lint:fix && npm run fmt", + "check": "npm run build:ink && npm run typecheck && npm run test", "test": "vitest run", "test:watch": "vitest" }, @@ -27,7 +29,7 @@ }, "devDependencies": { "@eslint/js": "^9", - "@types/node": "^24.13.2", + "@types/node": "^22.20.0", "@types/react": "^19.2.14", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", diff --git a/ui-tui/packages/hermes-ink/package.json b/ui-tui/packages/hermes-ink/package.json index ab6728a7c9f..9f4be632bef 100644 --- a/ui-tui/packages/hermes-ink/package.json +++ b/ui-tui/packages/hermes-ink/package.json @@ -4,7 +4,9 @@ "private": true, "type": "module", "scripts": { - "build": "esbuild src/entry-exports.ts --bundle --platform=node --format=esm --packages=external --outdir=dist" + "build": "esbuild src/entry-exports.ts --bundle --platform=node --format=esm --packages=external --outdir=dist", + "check": "npm run typecheck", + "typecheck": "tsc -b . --noEmit" }, "sideEffects": true, "main": "./index.js", @@ -49,6 +51,7 @@ "wrap-ansi": "^9.0.0" }, "devDependencies": { - "esbuild": "^0.28.1" + "esbuild": "^0.28.1", + "typescript": "^6.0.3" } } diff --git a/ui-tui/packages/hermes-ink/tsconfig.json b/ui-tui/packages/hermes-ink/tsconfig.json new file mode 100644 index 00000000000..21601918fe6 --- /dev/null +++ b/ui-tui/packages/hermes-ink/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} \ No newline at end of file diff --git a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts index bf409e95b35..d598f6a834c 100644 --- a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts +++ b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts @@ -86,10 +86,10 @@ describe('session orchestrator helpers', () => { it('turns model picker values into session-scoped draft model args', () => { expect(draftModelArgFromPickerValue('kimi-k2.6 --provider ollama-cloud --tui-session')).toBe( - 'kimi-k2.6 --provider ollama-cloud' + 'kimi-k2.6 --provider ollama-cloud --session' ) expect(draftModelArgFromPickerValue('openai/gpt-5.5 --provider openai-codex --global')).toBe( - 'openai/gpt-5.5 --provider openai-codex' + 'openai/gpt-5.5 --provider openai-codex --session' ) }) diff --git a/ui-tui/src/__tests__/approvalAction.test.ts b/ui-tui/src/__tests__/approvalAction.test.ts index 662fb71b960..0c817c64415 100644 --- a/ui-tui/src/__tests__/approvalAction.test.ts +++ b/ui-tui/src/__tests__/approvalAction.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { approvalAction } from '../components/prompts.js' +import { approvalAction, approvalOptions } from '../components/prompts.js' describe('approvalAction — pure key dispatch for ApprovalPrompt', () => { it('maps Esc to deny — parity with global Ctrl+C cancellation', () => { @@ -58,4 +58,23 @@ describe('approvalAction — pure key dispatch for ApprovalPrompt', () => { expect(approvalAction('', { downArrow: true }, 2, opts)).toEqual({ kind: 'noop' }) expect(approvalAction('', { return: true }, 2, opts)).toEqual({ kind: 'choose', choice: 'deny' }) }) + + it('offers only once and deny for Smart DENY owner override', () => { + const opts = approvalOptions({ allowPermanent: true, command: 'rm -rf /', description: 'blocked', smartDenied: true }) + + expect(opts).toEqual(['once', 'deny']) + expect(approvalAction('2', {}, 0, opts)).toEqual({ kind: 'choose', choice: 'deny' }) + expect(approvalAction('3', {}, 0, opts)).toEqual({ kind: 'noop' }) + }) + + it('uses explicit gateway choices as the prompt contract', () => { + expect( + approvalOptions({ + allowPermanent: true, + choices: ['once', 'deny'], + command: 'rm -rf /', + description: 'blocked' + }) + ).toEqual(['once', 'deny']) + }) }) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index fad5f6b4564..9103877bacc 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -949,6 +949,23 @@ describe('createGatewayEventHandler', () => { }) }) + it('preserves Smart DENY and explicit approval choices on the overlay', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ + payload: { + allow_permanent: true, + choices: ['once', 'deny'], + command: 'rm -rf /tmp/x', + description: 'smart deny override', + smart_denied: true + }, + type: 'approval.request' + } as any) + + expect(getOverlayState().approval).toMatchObject({ choices: ['once', 'deny'], smartDenied: true }) + }) + it('still surfaces terminal turn failures as errors', () => { const appended: Msg[] = [] const onEvent = createGatewayEventHandler(buildCtx(appended)) @@ -1307,6 +1324,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)', () => { diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index ca1af4cd9ab..e5f97c7e356 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -209,7 +209,7 @@ describe('createSlashHandler', () => { confirm_expensive_model: false, key: 'model', session_id: 'sid-abc', - value: 'anthropic/claude-sonnet-4.6 --provider openrouter' + value: 'anthropic/claude-sonnet-4.6 --provider openrouter --session' }) }) diff --git a/ui-tui/src/__tests__/cursorDriftRegression.test.ts b/ui-tui/src/__tests__/cursorDriftRegression.test.ts index 3f9082dcefc..4ab4e793639 100644 --- a/ui-tui/src/__tests__/cursorDriftRegression.test.ts +++ b/ui-tui/src/__tests__/cursorDriftRegression.test.ts @@ -68,7 +68,7 @@ describe('cursor-drift regression — composer cursorLayout matches Ink renderin ).toEqual(expected) } } - }) + }, 30_000) it('keeps cursor on the same row when text exactly fills the terminal width', () => { // wrap-ansi does NOT push exact-fill text onto a phantom next line. diff --git a/ui-tui/src/__tests__/orchestratorPromptSession.test.ts b/ui-tui/src/__tests__/orchestratorPromptSession.test.ts index f9ff16f34a5..2d670c4a5b8 100644 --- a/ui-tui/src/__tests__/orchestratorPromptSession.test.ts +++ b/ui-tui/src/__tests__/orchestratorPromptSession.test.ts @@ -32,7 +32,7 @@ describe('startPromptLiveSession', () => { 'rpc', { method: 'config.set', - params: { key: 'model', session_id: 'abc123', value: 'kimi-k2.6 --provider ollama-cloud' } + params: { key: 'model', session_id: 'abc123', value: 'kimi-k2.6 --provider ollama-cloud --session' } } ], ['sys', 'model → kimi-k2.6'], diff --git a/ui-tui/src/__tests__/statusRule.test.ts b/ui-tui/src/__tests__/statusRule.test.ts index 6af617a973d..9b8224b4078 100644 --- a/ui-tui/src/__tests__/statusRule.test.ts +++ b/ui-tui/src/__tests__/statusRule.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { busyIndicatorWidth, statusBarSegments, statusRuleWidths } from '../components/appChrome.js' +import { busyIndicatorWidth, StatusBarSegments, statusBarSegments, statusRuleWidths } from '../components/appChrome.js' describe('statusRuleWidths', () => { it('keeps the status rule within the terminal width', () => { @@ -69,8 +69,7 @@ describe('statusBarSegments', () => { voice: true, bg: true, subagents: true, - cost: true - }) + } satisfies StatusBarSegments) }) it('collapses the context bar to a token count on narrow terminals', () => { @@ -79,19 +78,17 @@ describe('statusBarSegments', () => { expect(s.compactCtx).toBe(true) expect(s.bar).toBe(false) expect(s.duration).toBe(false) - expect(s.cost).toBe(false) }) it('sheds tail segments in priority order as the terminal narrows', () => { - // cost is the first to go, the context bar the last of the tail. + // the context bar is the last of the tail to go. const order: (keyof ReturnType)[] = [ 'bar', 'duration', 'compressions', 'voice', 'bg', - 'subagents', - 'cost' + 'subagents' ] let prevCount = Infinity diff --git a/ui-tui/src/__tests__/submissionCore.test.ts b/ui-tui/src/__tests__/submissionCore.test.ts index 83b89a088c8..23b83e87468 100644 --- a/ui-tui/src/__tests__/submissionCore.test.ts +++ b/ui-tui/src/__tests__/submissionCore.test.ts @@ -38,7 +38,6 @@ function makeDeps(gw: GatewayClient, over: Partial = {}): Subm enqueue: vi.fn(), expand: (t: string) => t, gw, - maybeGoodVibes: vi.fn(), setLastUserMsg: vi.fn(), sys: vi.fn(), ...over diff --git a/ui-tui/src/__tests__/textInputCursorSourceOfTruth.test.ts b/ui-tui/src/__tests__/textInputCursorSourceOfTruth.test.ts index b52894d1587..e6998d9488e 100644 --- a/ui-tui/src/__tests__/textInputCursorSourceOfTruth.test.ts +++ b/ui-tui/src/__tests__/textInputCursorSourceOfTruth.test.ts @@ -1,13 +1,7 @@ -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - import { describe, expect, it } from 'vitest' -// Locate textInput.tsx relative to this test file so the assertion -// survives moves of the test fixture itself. -const TEXT_INPUT_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'components', 'textInput.tsx') -const source = readFileSync(TEXT_INPUT_PATH, 'utf8') +import { cursorLayout } from '../lib/inputMetrics.js' +import { fastAppendEffect, fastBackspaceEffect, resolveCursorLayout } from '../components/textInput.js' // Closes Copilot follow-up on PR #26717: the original cursor-drift // fix bumped Ink's displayCursor / cursorDeclaration on fast-echo, but @@ -18,33 +12,89 @@ const source = readFileSync(TEXT_INPUT_PATH, 'utf8') // bump. The fix is structural: read `curRef.current` (always // up-to-date) when computing the layout, not the `cur` state. // -// This file pins that invariant. Switching back to `cur` state — or -// re-introducing a memo keyed on `cur` that uses `curRef.current` -// inside but stops re-computing on rerender — is a regression and -// should be caught here, not via a flaky integration test that mounts -// Ink + stdin. -describe('textInput cursor-layout source of truth', () => { - it('reads curRef.current (not the cur React state) for cursorLayout', () => { - // The line we care about. We allow whitespace / formatting drift, - // but the call itself must use `curRef.current`. - expect(source).toMatch(/cursorLayout\(\s*display\s*,\s*curRef\.current\s*,\s*columns\s*\)/) +// These tests exercise the real, exported `resolveCursorLayout`, +// `fastBackspaceEffect`, and `fastAppendEffect` helpers that +// `textInput.tsx` calls at its render site and fast-echo call sites — +// no source-text regex, no readFileSync. +describe('resolveCursorLayout', () => { + it('uses curRefCurrent (the fresh ref value), not the stale cur state', () => { + // Simulate the exact bug scenario: `cur` (React state) is stale — + // it still reflects the value before a fast-echo append — while + // `curRef.current` has already advanced past it. + const display = 'hello world' + const staleCur = 5 + const freshCurRefCurrent = 11 + const columns = 80 + + const result = resolveCursorLayout(display, staleCur, freshCurRefCurrent, columns) + const expected = cursorLayout(display, freshCurRefCurrent, columns) + + expect(result).toEqual(expected) }) - it('does not pass the bare `cur` React state into cursorLayout', () => { - // Any `cursorLayout(display, cur, columns)` invocation would - // reintroduce the stale-declaration window. - expect(source).not.toMatch(/cursorLayout\(\s*display\s*,\s*cur\s*,\s*columns\s*\)/) + it('does not match the layout computed from the stale cur value', () => { + const display = 'hello world' + const staleCur = 5 + const freshCurRefCurrent = 11 + const columns = 80 + + const result = resolveCursorLayout(display, staleCur, freshCurRefCurrent, columns) + const staleLayout = cursorLayout(display, staleCur, columns) + + expect(result).not.toEqual(staleLayout) }) - it('keeps the fast-echo notifier calls paired with the stdout writes', () => { - // Both fast-echo paths must call noteCursorAdvance, otherwise Ink - // never learns about the out-of-band write and drifts again. We - // tolerate explanatory comments in between (the rationale block is - // intentionally long), but the pairing itself must hold. - const backspacePattern = /stdout!\.write\(['"`]\\b \\b['"`]\)[\s\S]{0,1000}?noteCursorAdvance\(-1\)/ - expect(source).toMatch(backspacePattern) + it('matches cursorLayout(display, curRefCurrent, columns) even when cur and curRefCurrent agree', () => { + const display = 'hello' + const cur = 5 + const columns = 80 - const appendPattern = /stdout!\.write\(text\)[\s\S]{0,1000}?noteCursorAdvance\(text\.length\)/ - expect(source).toMatch(appendPattern) + expect(resolveCursorLayout(display, cur, cur, columns)).toEqual(cursorLayout(display, cur, columns)) + }) +}) + +describe('fastBackspaceEffect', () => { + it('removes the last character, moves the cursor back one, and pairs the write with the advance delta', () => { + const effect = fastBackspaceEffect('hello', 5) + + expect(effect.newValue).toBe('hell') + expect(effect.newCursor).toBe(4) + expect(effect.removed).toBe('o') + // Both the stdout write and the noteCursorAdvance delta live on the + // same returned object — a caller cannot apply `write` without also + // having `advanceDelta` in hand, so the pairing can't silently drift. + expect(effect.write).toBe('\b \b') + expect(effect.advanceDelta).toBe(-1) + }) + + it('handles deleting from the middle of the fast-echo-eligible tail', () => { + const effect = fastBackspaceEffect('abc', 3) + + expect(effect.newValue).toBe('ab') + expect(effect.newCursor).toBe(2) + expect(effect.removed).toBe('c') + expect(effect.write).toBe('\b \b') + expect(effect.advanceDelta).toBe(-1) + }) +}) + +describe('fastAppendEffect', () => { + it('appends the text, advances the cursor by the inserted length, and pairs the write with the advance delta', () => { + const effect = fastAppendEffect('hello', 5, ' world') + + expect(effect.newValue).toBe('hello world') + expect(effect.newCursor).toBe(11) + // The stdout write is exactly the inserted text, and the + // noteCursorAdvance delta is bundled into the same object. + expect(effect.write).toBe(' world') + expect(effect.advanceDelta).toBe(' world'.length) + }) + + it('advance delta always matches the inserted text length, not a hardcoded value', () => { + const effect = fastAppendEffect('x', 1, 'abc') + + expect(effect.newValue).toBe('xabc') + expect(effect.advanceDelta).toBe(3) + expect(effect.write).toBe('abc') }) }) diff --git a/ui-tui/src/__tests__/useInputHandlers.test.ts b/ui-tui/src/__tests__/useInputHandlers.test.ts index fa9372d5356..ef9e676f73e 100644 --- a/ui-tui/src/__tests__/useInputHandlers.test.ts +++ b/ui-tui/src/__tests__/useInputHandlers.test.ts @@ -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 + }) +}) diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index 9819a7214da..17cd32fec8d 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -18,10 +18,13 @@ describe('virtual height estimates', () => { }) it('uses compound user prompt width when estimating user message wrapping', () => { - const msg: Msg = { role: 'user', text: 'x'.repeat(21) } + // cols must clear the 20-col body-width floor for both prompts (gutter + + // horizontalReserve=4) so the wider 'Ψ >' prompt actually narrows the + // body enough to wrap an extra line vs the single-cell '❯' prompt. + const msg: Msg = { role: 'user', text: 'x'.repeat(23) } - expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: '❯' })).toBe(3) - expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: 'Ψ >' })).toBe(4) + expect(estimatedMsgHeight(msg, 30, { compact: false, details: false, userPrompt: '❯' })).toBe(3) + expect(estimatedMsgHeight(msg, 30, { compact: false, details: false, userPrompt: 'Ψ >' })).toBe(4) }) it('adds one row for a group-boundary lead gap', () => { diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index d9cbf30663e..051f0934777 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -20,7 +20,7 @@ import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { getOverlayState, patchOverlayState } from './overlayStore.js' -import { flashPet } from './petFlashStore.js' +import { flashGoodVibes, flashPet } from './petFlashStore.js' import { turnController } from './turnController.js' import { getTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' @@ -717,6 +717,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return + case 'reaction': + // Core-detected affection (ily / <3 / good bot): flash the ♥ and let the + // pet celebrate. Same signal drives the desktop's floating hearts. + flashGoodVibes() + flashPet('jump') + + return + case 'tool.start': turnController.recordTodos(ev.payload.todos) turnController.recordToolStart( @@ -778,7 +786,13 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const allowPermanent = ev.payload.allow_permanent !== false patchOverlayState({ - approval: { allowPermanent, command: String(ev.payload.command ?? ''), description } + approval: { + allowPermanent, + choices: ev.payload.choices, + command: String(ev.payload.command ?? ''), + description, + smartDenied: ev.payload.smart_denied === true + } }) setStatus('approval needed') @@ -799,6 +813,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}`) diff --git a/ui-tui/src/app/petFlashStore.ts b/ui-tui/src/app/petFlashStore.ts index b1ecf97e995..48841a3c4be 100644 --- a/ui-tui/src/app/petFlashStore.ts +++ b/ui-tui/src/app/petFlashStore.ts @@ -14,6 +14,13 @@ export const $petFlash = atom(null) export const flashPet = (state: PetState, ms = 1600) => $petFlash.set({ state, until: Date.now() + ms }) +// Affection-heart beat: a monotonic tick the status-bar ♥ flashes on. Bumped by +// the gateway `reaction` event (core-detected ily / <3 / good bot) — the TUI's +// share of the same signal that plays the desktop's floating hearts. +export const $goodVibesTick = atom(0) + +export const flashGoodVibes = () => $goodVibesTick.set($goodVibesTick.get() + 1) + // The floating pet's footprint, or null when no pet is shown. The transcript // keeps its text clear of the pet responsively: on wide terminals it reserves a // right gutter (`width`) so lines wrap to the pet's LEFT; on narrow terminals it diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 6b1fe55481b..ce1c4ead945 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -1,5 +1,5 @@ import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js' -import { TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' +import { sessionScopedModelArg, TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' import type { BackgroundStartResponse, ConfigGetValueResponse, @@ -20,10 +20,6 @@ import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`) -const TUI_SESSION_STRIP_RE = new RegExp(`\\s*${TUI_SESSION_MODEL_FLAG}\\b\\s*`, 'g') - -const stripTuiSessionFlag = (trimmed: string) => trimmed.replace(TUI_SESSION_STRIP_RE, ' ').replace(/\s+/g, ' ').trim() - const modelValueForConfigSet = (arg: string) => { const trimmed = arg.trim() @@ -32,7 +28,7 @@ const modelValueForConfigSet = (arg: string) => { } if (TUI_SESSION_MODEL_RE.test(trimmed)) { - return stripTuiSessionFlag(trimmed) + return sessionScopedModelArg(trimmed) } return trimmed diff --git a/ui-tui/src/app/submissionCore.ts b/ui-tui/src/app/submissionCore.ts index 7c561b745ba..0ec3921bb3f 100644 --- a/ui-tui/src/app/submissionCore.ts +++ b/ui-tui/src/app/submissionCore.ts @@ -15,7 +15,6 @@ export interface SubmitPromptDeps { enqueue: (text: string) => void expand: (text: string) => string gw: GatewayClient - maybeGoodVibes: (text: string) => void setLastUserMsg: (value: string) => void sys: (text: string) => void } @@ -61,7 +60,6 @@ export function submitPrompt(text: string, deps: SubmitPromptDeps, showUserMessa } turnController.clearStatusTimer() - deps.maybeGoodVibes(submitText) deps.setLastUserMsg(text) if (show) { diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 2f95c565e85..3461cd241a8 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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, + rpc: GatewayRpc, + sys: (text: string) => void +) { + if (overlay.sudo) { + const requestId = overlay.sudo.requestId + + patchOverlayState({ sudo: null }) + sys('sudo cancelled') + + return rpc('sudo.respond', { password: '', request_id: requestId }) + } + + if (overlay.secret) { + const requestId = overlay.secret.requestId + + patchOverlayState({ secret: null }) + sys('secret entry cancelled') + + return rpc('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('sudo.respond', { password: '', request_id: overlay.sudo.requestId }) - .then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled'))) - } - - if (overlay.secret) { - return gateway - .rpc('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 }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 33ab9b2f3e3..a7b5e2d2e4e 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -9,6 +9,7 @@ import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js' import { SECTION_NAMES, sectionMode } from '../domain/details.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js' +import { sessionScopedModelArg } from '../domain/slash.js' import { type GatewayClient } from '../gatewayClient.js' import type { ClarifyRespondResponse, @@ -37,6 +38,7 @@ import { planGatewayRecovery } from './gatewayRecovery.js' import { getInputSelection } from './inputSelectionStore.js' import { type GatewayRpc, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' +import { $goodVibesTick } from './petFlashStore.js' import { scrollWithSelectionBy } from './scroll.js' import { turnController } from './turnController.js' import { patchTurnState, useTurnSelector } from './turnStore.js' @@ -48,7 +50,6 @@ import { useLongRunToolCharms } from './useLongRunToolCharms.js' import { useSessionLifecycle } from './useSessionLifecycle.js' import { useSubmission } from './useSubmission.js' -const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i const BRACKET_PASTE_ON = '\x1b[?2004h' const BRACKET_PASTE_OFF = '\x1b[?2004l' const MAX_HEIGHT_CACHE_BUCKETS = 12 @@ -116,7 +117,7 @@ export async function startPromptLiveSession({ return null } - const requestedModel = modelArg?.trim() + const requestedModel = modelArg ? sessionScopedModelArg(modelArg) : '' if (requestedModel) { const result = await rpc('config.set', { key: 'model', session_id: sid, value: requestedModel }) @@ -185,7 +186,8 @@ export function useMainApp(gw: GatewayClient) { const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [turnStartedAt, setTurnStartedAt] = useState(null) const [lastTurnEndedAt, setLastTurnEndedAt] = useState(null) - const [goodVibesTick, setGoodVibesTick] = useState(0) + // Bumped by the gateway `reaction` event (core-detected affection). + const goodVibesTick = useStore($goodVibesTick) const [bellOnComplete, setBellOnComplete] = useState(false) const ui = useStore($uiState) @@ -445,12 +447,6 @@ export function useMainApp(gw: GatewayClient) { [sys] ) - const maybeGoodVibes = useCallback((text: string) => { - if (GOOD_VIBES_RE.test(text)) { - setGoodVibesTick(v => v + 1) - } - }, []) - const rpc: GatewayRpc = useCallback( async = Record>( method: string, @@ -690,7 +686,6 @@ export function useMainApp(gw: GatewayClient) { composerRefs, composerState, gw, - maybeGoodVibes, setLastUserMsg, slashRef, submitRef, @@ -916,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…' }) }) @@ -930,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…' }) }) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 6ece0bf6412..6e02d349a1d 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -37,7 +37,6 @@ export function useSubmission(opts: UseSubmissionOptions) { composerRefs, composerState, gw, - maybeGoodVibes, setLastUserMsg, slashRef, submitRef, @@ -87,14 +86,13 @@ export function useSubmission(opts: UseSubmissionOptions) { enqueue: composerActions.enqueue, expand, gw, - maybeGoodVibes, setLastUserMsg, sys }, showUserMessage ) }, - [appendMessage, composerActions, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys] + [appendMessage, composerActions, composerState.pasteSnips, gw, setLastUserMsg, sys] ) const shellExec = useCallback( @@ -362,7 +360,6 @@ export interface UseSubmissionOptions { composerRefs: ComposerRefs composerState: ComposerState gw: GatewayClient - maybeGoodVibes: (text: string) => void setLastUserMsg: (value: string) => void slashRef: MutableRefObject<(cmd: string) => boolean> submitRef: MutableRefObject<(value: string) => void> diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index af4fbbb27fb..4ac3385420c 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -1,7 +1,7 @@ import { Box, Text, useInput, useStdout } from '@hermes/ink' import { useCallback, useEffect, useRef, useState } from 'react' -import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' +import { sessionScopedModelArg } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' import type { SessionActiveItem, @@ -205,18 +205,7 @@ export const closeFallbackAfterClose = ( } export const draftModelArgFromPickerValue = (value: string) => { - const parts = value.trim().split(/\s+/).filter(Boolean) - const kept: string[] = [] - - for (const part of parts) { - if (part === TUI_SESSION_MODEL_FLAG || part === '--global') { - continue - } - - kept.push(part) - } - - return kept.join(' ') + return sessionScopedModelArg(value) } export const draftModelNameFromArg = (value: string) => { diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index acac12eef18..bfd7bf62d27 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -10,11 +10,24 @@ import { TextInput } from './textInput.js' const APPROVAL_OPTS = ['once', 'session', 'always', 'deny'] as const // tirith warning present → backend downgrades "always" to session scope, so drop it. const APPROVAL_OPTS_NO_ALWAYS = APPROVAL_OPTS.filter(o => o !== 'always') +const APPROVAL_OPTS_SMART_DENY = ['once', '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 ApprovalChoice = 'always' | 'deny' | 'once' | 'session' +export function approvalOptions(req: ApprovalReq): readonly ApprovalChoice[] { + if (req.choices) { + return req.choices.filter((choice): choice is ApprovalChoice => APPROVAL_OPTS.includes(choice as ApprovalChoice)) + } + + if (req.smartDenied) { + return APPROVAL_OPTS_SMART_DENY + } + + return req.allowPermanent === false ? APPROVAL_OPTS_NO_ALWAYS : APPROVAL_OPTS +} + type ApprovalKey = { downArrow?: boolean escape?: boolean @@ -68,7 +81,7 @@ export function approvalAction( export function ApprovalPrompt({ cols = 80, onChoice, req, t }: ApprovalPromptProps) { const [sel, setSel] = useState(0) - const opts = req.allowPermanent === false ? APPROVAL_OPTS_NO_ALWAYS : APPROVAL_OPTS + const opts = approvalOptions(req) useInput((ch, key) => { const action = approvalAction(ch, key, sel, opts) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 9cbebd416f4..9fed82096d2 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -264,6 +264,81 @@ const ASCII_PRINTABLE_RE = /^[\x20-\x7e]+$/ * length 1 but are still produced by IME compositions and must not be * fast-echoed. */ +/** + * Resolves which cursor position `cursorLayout` should be computed from. + * + * The fast-echo path defers the React `setCur` by 16ms to batch + * re-renders during heavy typing. If an unrelated render flushes this + * component during that window and the layout used the stale `cur` + * React state, the layout effect inside `useDeclaredCursor` would + * publish a stale cursor declaration and clobber the Ink-level bump + * from `noteCursorAdvance(...)` (the cursor-drift regression closed by + * PR #26717's Copilot follow-up). `curRef.current` is always + * up-to-date, so it — never the possibly-stale `cur` state — must be + * the source of truth here. + * + * Extracted as a pure function (rather than inlining `curRef.current` + * directly at the call site) so the invariant is unit-testable without + * mounting Ink/React: construct a scenario where `cur` and + * `curRefCurrent` genuinely diverge and assert the layout matches the + * fresh ref value, not the stale state. + */ +export function resolveCursorLayout(display: string, cur: number, curRefCurrent: number, columns: number) { + void cur // intentionally unused for layout — see doc comment above + + return cursorLayout(display, curRefCurrent, columns) +} + +/** + * Pure computation for the fast-echo backspace bypass: given the + * current value/cursor (already validated by `canFastBackspaceShape`), + * returns what the new value/cursor should be, the exact stdout write + * ("\b \b"), and the delta to report to Ink's `noteCursorAdvance`. + * + * Bundling the write + notifier delta into a single return value means + * the "every fast-echo write must be paired with a matching + * noteCursorAdvance call" invariant is enforced by the return shape + * itself (a caller can't apply `write` without also having + * `advanceDelta` in hand) rather than by two independent call sites + * that happen to sit near each other in source. + */ +export function fastBackspaceEffect( + current: string, + cursor: number +): { advanceDelta: number; newCursor: number; newValue: string; removed: string; write: string } { + const t = prevPos(current, cursor) + const removed = current.slice(t, cursor) + + return { + advanceDelta: -1, + newCursor: t, + newValue: current.slice(0, t) + current.slice(cursor), + removed, + write: '\b \b' + } +} + +/** + * Pure computation for the fast-echo append bypass: given the current + * value/cursor (already validated by `canFastAppendShape`) and the + * inserted text, returns the new value/cursor, the exact stdout write + * (the inserted text itself), and the delta to report to Ink's + * `noteCursorAdvance`. See `fastBackspaceEffect` for why write + delta + * are bundled into one return value. + */ +export function fastAppendEffect( + current: string, + cursor: number, + text: string +): { advanceDelta: number; newCursor: number; newValue: string; write: string } { + return { + advanceDelta: text.length, + newCursor: cursor + text.length, + newValue: current.slice(0, cursor) + text + current.slice(cursor), + write: text + } +} + export function canFastAppendShape( current: string, cursor: number, @@ -517,7 +592,7 @@ export function TextInput({ // for layout. The cursorLayout call is cheap (one wrap-text pass // over a single-line string in the common case), so dropping useMemo // is fine. - const layout = cursorLayout(display, curRef.current, columns) + const layout = resolveCursorLayout(display, cur, curRef.current, columns) const boxRef = useDeclaredCursor({ line: layout.line, @@ -1080,16 +1155,16 @@ export function TextInput({ v = v.slice(0, t) + v.slice(c) c = t } else if (canFastBackspace(v, c)) { - const t = prevPos(v, c) - v = v.slice(0, t) + v.slice(c) - c = t - stdout!.write('\b \b') + const effect = fastBackspaceEffect(v, c) + v = effect.newValue + c = effect.newCursor + stdout!.write(effect.write) // The "\b \b" sequence ends with the cursor one column to the // LEFT of where Ink last parked it. Tell Ink so its `displayCursor` // (and log-update's relative-move basis on the next frame) stays // in sync — otherwise the cursor parks one cell to the right of // the caret on the next unrelated re-render. - noteCursorAdvance(-1) + noteCursorAdvance(effect.advanceDelta) commit(v, c, true, false, false, Math.max(0, lineWidthRef.current - 1)) return @@ -1184,12 +1259,15 @@ export function TextInput({ c = inserted.cursor } else { const simpleAppend = canFastAppend(v, c, text) + const preInsertValue = v + const preInsertCursor = c v = inserted.value c = inserted.cursor if (simpleAppend) { - stdout!.write(text) + const effect = fastAppendEffect(preInsertValue, preInsertCursor, text) + stdout!.write(effect.write) // ASCII-printable text advances the physical cursor by exactly // text.length cells (canFastAppendShape rejects non-ASCII, // wide chars, newlines). Notify Ink so the cached displayCursor @@ -1197,7 +1275,7 @@ export function TextInput({ // any unrelated re-render that happens before the 16ms // setCur/setParent flush parks the cursor text.length cells // too far right (#cursor-drift). - noteCursorAdvance(text.length) + noteCursorAdvance(effect.advanceDelta) commit(v, c, true, false, false, lineWidthRef.current + stringWidth(text)) return diff --git a/ui-tui/src/domain/slash.ts b/ui-tui/src/domain/slash.ts index 42962ae69d4..b86c34d134c 100644 --- a/ui-tui/src/domain/slash.ts +++ b/ui-tui/src/domain/slash.ts @@ -1,6 +1,13 @@ -/** Appended to `/model` args from the TUI picker for session scope; stripped in `session` slash before `config.set`. */ +/** Appended by TUI pickers; converted to the backend's `--session` flag before `config.set`. */ export const TUI_SESSION_MODEL_FLAG = '--tui-session' +export const sessionScopedModelArg = (value: string) => { + const parts = value.trim().split(/\s+/).filter(Boolean) + const kept = parts.filter(part => part !== TUI_SESSION_MODEL_FLAG && part !== '--global' && part !== '--session') + + return kept.length ? `${kept.join(' ')} --session` : '' +} + export const looksLikeSlashCommand = (text: string) => /^\/[^\s/]*(?:\s|$)/.test(text) export const parseSlashCommand = (cmd: string) => { diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index ee6b8d78c45..46e3019fde3 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -618,6 +618,7 @@ export type GatewayEvent = | { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' } | { payload: SessionInfo; session_id?: string; type: 'session.info' } | { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' } + | { payload?: { kind?: string }; session_id?: string; type: 'reaction' } | { payload?: undefined; session_id?: string; type: 'message.start' } | { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' } | { @@ -691,12 +692,13 @@ export type GatewayEvent = type: 'clarify.request' } | { - payload: { allow_permanent?: boolean; command: string; description: string } + payload: { allow_permanent?: boolean; choices?: string[]; command: string; description: string; smart_denied?: boolean } session_id?: string type: 'approval.request' } | { 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' } diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 14ab98ca18d..6f6818e37cd 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -92,8 +92,10 @@ export interface DelegationStatus { export interface ApprovalReq { // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow". allowPermanent?: boolean + choices?: string[] command: string description: string + smartDenied?: boolean } export interface ConfirmReq { diff --git a/uv.lock b/uv.lock index f70435a9506..21bd0827c77 100644 --- a/uv.lock +++ b/uv.lock @@ -1794,7 +1794,7 @@ requires-dist = [ { name = "honcho-ai", marker = "extra == 'honcho'", specifier = "==2.0.1" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "jinja2", specifier = "==3.1.6" }, - { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.5.3" }, + { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.6.8" }, { name = "markdown", specifier = "==3.10.2" }, { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.0" }, { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" }, @@ -2184,7 +2184,7 @@ wheels = [ [[package]] name = "lark-oapi" -version = "1.5.3" +version = "1.6.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2194,7 +2194,7 @@ dependencies = [ { name = "websockets" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016, upload-time = "2026-01-27T08:21:49.307Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ad/1ab04db5d549ad1a7a2cd33682b1c38dee1d65019cb24fd7a23270e6337d/lark_oapi-1.6.8-py3-none-any.whl", hash = "sha256:9b443a5d47a7d204dd42dc40896c8b75087cc35788e45c48c140806d7df7e5e8", size = 7798300, upload-time = "2026-06-02T07:40:05.492Z" }, ] [[package]] diff --git a/web/package.json b/web/package.json index 0e9987b51b3..8dfe84074a0 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,8 @@ "lint": "eslint .", "preview": "vite preview", "typecheck": "tsc -p . --noEmit", - "test": "vitest run" + "test": "vitest run", + "check": "npm run typecheck && npm run test" }, "dependencies": { "@hermes/shared": "file:../apps/shared", @@ -38,7 +39,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", - "@types/node": "^24.13.2", + "@types/node": "^22.20.0", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 47cb5e72b05..e6c6d09e0a1 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -382,7 +382,7 @@ export function ChatSidebar({ onClick={reconnect} prefix={} > - reconnect + reconnect tools feed )} diff --git a/web/src/components/OAuthLoginModal.tsx b/web/src/components/OAuthLoginModal.tsx index f35fd81575b..dd815c2ee07 100644 --- a/web/src/components/OAuthLoginModal.tsx +++ b/web/src/components/OAuthLoginModal.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from "react"; -import { ExternalLink, X, Check } from "lucide-react"; +import { ExternalLink, X, Check, Copy } from "lucide-react"; import { Button } from "@nous-research/ui/ui/components/button"; -import { CopyButton } from "@nous-research/ui/ui/components/command-block"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { Input } from "@nous-research/ui/ui/components/input"; import { useI18n } from "@/i18n"; import { cn, themedBody } from "@/lib/utils"; @@ -30,9 +30,13 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { const [start, setStart] = useState(null); const [pkceCode, setPkceCode] = useState(""); const [errorMsg, setErrorMsg] = useState(null); + const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">( + "idle", + ); const [secondsLeft, setSecondsLeft] = useState(null); const isMounted = useRef(true); const pollTimer = useRef(null); + const copyResetTimer = useRef(null); const { t } = useI18n(); // Initiate flow on mount @@ -59,6 +63,8 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { return () => { isMounted.current = false; if (pollTimer.current !== null) window.clearInterval(pollTimer.current); + if (copyResetTimer.current !== null) + window.clearTimeout(copyResetTimer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -162,6 +168,24 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { return `${m}:${String(r).padStart(2, "0")}`; }; + const handleCopyDeviceCode = async (code: string) => { + if (copyResetTimer.current !== null) { + window.clearTimeout(copyResetTimer.current); + copyResetTimer.current = null; + } + const copied = await copyTextToClipboard(code); + if (!isMounted.current) return; + setCopyStatus(copied ? "copied" : "failed"); + copyResetTimer.current = window.setTimeout(() => { + if (isMounted.current) setCopyStatus("idle"); + copyResetTimer.current = null; + }, 2000); + }; + + const deviceCode = start?.flow === "device_code" ? start.user_code : ""; + const verificationUrl = + start?.flow === "device_code" ? start.verification_url : ""; + return (
- { - ( - start as Extract< - OAuthStartResponse, - { flow: "device_code" } - > - ).user_code - } + {deviceCode} - - ).user_code +
+ {copyStatus === "failed" && ( +

+ {t.oauth.copyFailed} +

+ )} - ).verification_url - } + href={verificationUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" diff --git a/web/src/i18n/af.ts b/web/src/i18n/af.ts index 63d7342c707..8a7c8a1b96e 100644 --- a/web/src/i18n/af.ts +++ b/web/src/i18n/af.ts @@ -446,16 +446,19 @@ export const af: Translations = { oauth: { title: "Verskaffer-aanmeldings (OAuth)", providerLogins: "Verskaffer-aanmeldings (OAuth)", - description: "{connected} van {total} OAuth-verskaffers gekoppel. Aanmeldvloei loop tans via die CLI; klik Kopieer opdrag en plak in 'n terminaal om op te stel.", + description: + "{connected} van {total} OAuth-verskaffers gekoppel. Gebruik Meld aan vir vloeie wat die kontroleskerm ondersteun; CLI-opdragte bly beskikbaar vir eksterne of terugval-opstelling.", connected: "Gekoppel", expired: "Verval", - notConnected: "Nie gekoppel nie. Voer {command} uit in 'n terminaal.", + notConnected: "Nie gekoppel nie. Gebruik Meld aan indien beskikbaar, of voer {command} uit in 'n terminaal.", runInTerminal: "in 'n terminaal.", noProviders: "Geen OAuth-bekwame verskaffers opgespoor nie.", login: "Meld aan", disconnect: "Ontkoppel", managedExternally: "Ekstern bestuur", copied: "Gekopieer ✓", + copyCode: "Kopieer kode", + copyFailed: "Kon nie outomaties kopieer nie. Kies die kode en kopieer dit met die hand.", cli: "Kopieer", copyCliCommand: "Kopieer CLI-opdrag (vir ekstern / terugval)", connect: "Koppel", diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 4f316710406..4a5bcb23461 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -446,16 +446,19 @@ export const de: Translations = { oauth: { title: "Anbieter-Logins (OAuth)", providerLogins: "Anbieter-Logins (OAuth)", - description: "{connected} von {total} OAuth-Anbietern verbunden. Login-Abläufe laufen derzeit über die CLI; klicke auf Befehl kopieren und füge ihn in ein Terminal ein, um einzurichten.", + description: + "{connected} von {total} OAuth-Anbietern verbunden. Nutze Anmelden für vom Dashboard unterstützte Abläufe; CLI-Befehle bleiben für externe oder Fallback-Einrichtung verfügbar.", connected: "Verbunden", expired: "Abgelaufen", - notConnected: "Nicht verbunden. Führe {command} in einem Terminal aus.", + notConnected: "Nicht verbunden. Nutze Anmelden, falls verfügbar, oder führe {command} in einem Terminal aus.", runInTerminal: "in einem Terminal.", noProviders: "Keine OAuth-fähigen Anbieter erkannt.", login: "Anmelden", disconnect: "Trennen", managedExternally: "Extern verwaltet", copied: "Kopiert ✓", + copyCode: "Code kopieren", + copyFailed: "Automatisches Kopieren fehlgeschlagen. Markiere den Code und kopiere ihn manuell.", cli: "Kopieren", copyCliCommand: "CLI-Befehl kopieren (für extern / Fallback)", connect: "Verbinden", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index e2ba0cc0369..a8be9310835 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -501,16 +501,19 @@ export const en: Translations = { oauth: { title: "Provider Logins (OAuth)", providerLogins: "Provider Logins (OAuth)", - description: "{connected} of {total} OAuth providers connected. Login flows currently run via the CLI; click Copy command and paste into a terminal to set up.", + description: + "{connected} of {total} OAuth providers connected. Use Login for dashboard-supported flows; CLI commands remain available for external or fallback setup.", connected: "Connected", expired: "Expired", - notConnected: "Not connected. Run {command} in a terminal.", + notConnected: "Not connected. Use Login when available, or run {command} in a terminal.", runInTerminal: "in a terminal.", noProviders: "No OAuth-capable providers detected.", login: "Login", disconnect: "Disconnect", managedExternally: "Managed externally", copied: "Copied ✓", + copyCode: "Copy code", + copyFailed: "Could not copy automatically. Select the code and copy it manually.", cli: "Copy", copyCliCommand: "Copy CLI command (for external / fallback)", connect: "Connect", diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index 2cf46f3007c..fd209b5c1d6 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -447,16 +447,19 @@ export const es: Translations = { oauth: { title: "Inicios de sesión de proveedores (OAuth)", providerLogins: "Inicios de sesión de proveedores (OAuth)", - description: "{connected} de {total} proveedores OAuth conectados. Los flujos de inicio de sesión actualmente se ejecutan a través de la CLI; haz clic en Copiar comando y pégalo en una terminal para configurar.", + description: + "{connected} de {total} proveedores OAuth conectados. Usa Iniciar sesión para los flujos compatibles con el panel; los comandos CLI siguen disponibles para configuración externa o de respaldo.", connected: "Conectado", expired: "Caducado", - notConnected: "No conectado. Ejecuta {command} en una terminal.", + notConnected: "No conectado. Usa Iniciar sesión si está disponible, o ejecuta {command} en una terminal.", runInTerminal: "en una terminal.", noProviders: "No se han detectado proveedores compatibles con OAuth.", login: "Iniciar sesión", disconnect: "Desconectar", managedExternally: "Gestionado externamente", copied: "Copiado ✓", + copyCode: "Copiar código", + copyFailed: "No se pudo copiar automáticamente. Selecciona el código y cópialo manualmente.", cli: "Copiar", copyCliCommand: "Copiar comando CLI (para externo / alternativa)", connect: "Conectar", diff --git a/web/src/i18n/fr.ts b/web/src/i18n/fr.ts index 7c321b0d196..f822232cc69 100644 --- a/web/src/i18n/fr.ts +++ b/web/src/i18n/fr.ts @@ -447,16 +447,19 @@ export const fr: Translations = { oauth: { title: "Connexions fournisseurs (OAuth)", providerLogins: "Connexions fournisseurs (OAuth)", - description: "{connected} sur {total} fournisseurs OAuth connectés. Les flux de connexion s'exécutent actuellement via le CLI ; cliquez sur Copier la commande et collez-la dans un terminal pour configurer.", + description: + "{connected} sur {total} fournisseurs OAuth connectés. Utilisez Connexion pour les flux pris en charge par le tableau de bord ; les commandes CLI restent disponibles pour une configuration externe ou de secours.", connected: "Connecté", expired: "Expiré", - notConnected: "Non connecté. Exécutez {command} dans un terminal.", + notConnected: "Non connecté. Utilisez Connexion si disponible, ou exécutez {command} dans un terminal.", runInTerminal: "dans un terminal.", noProviders: "Aucun fournisseur compatible OAuth détecté.", login: "Connexion", disconnect: "Déconnecter", managedExternally: "Géré en externe", copied: "Copié ✓", + copyCode: "Copier le code", + copyFailed: "Copie automatique impossible. Sélectionnez le code et copiez-le manuellement.", cli: "Copier", copyCliCommand: "Copier la commande CLI (pour externe / repli)", connect: "Connecter", diff --git a/web/src/i18n/ga.ts b/web/src/i18n/ga.ts index 2bd97ed5a58..7f7f82ef191 100644 --- a/web/src/i18n/ga.ts +++ b/web/src/i18n/ga.ts @@ -454,16 +454,19 @@ export const ga: Translations = { oauth: { title: "Logálacha isteach soláthraí (OAuth)", providerLogins: "Logálacha isteach soláthraí (OAuth)", - description: "{connected} as {total} soláthraí OAuth ceangailte. Reáchtáiltear sreabha logála isteach faoi láthair tríd an CLI; cliceáil Cóipeáil ordú agus greamaigh i dteirminéal chun é a shocrú.", + description: + "{connected} as {total} soláthraí OAuth ceangailte. Úsáid Logáil isteach le haghaidh sreabha a dtacaíonn an deais leo; tá orduithe CLI ar fáil i gcónaí do shocrú seachtrach nó cúltaca.", connected: "Ceangailte", expired: "As feidhm", - notConnected: "Gan cheangal. Rith {command} i dteirminéal.", + notConnected: "Gan cheangal. Úsáid Logáil isteach má tá sé ar fáil, nó rith {command} i dteirminéal.", runInTerminal: "i dteirminéal.", noProviders: "Níor aimsíodh soláthraithe a thacaíonn le OAuth.", login: "Logáil isteach", disconnect: "Dícheangail", managedExternally: "Bainistithe go seachtrach", copied: "Cóipeáilte ✓", + copyCode: "Cóipeáil an cód", + copyFailed: "Níorbh fhéidir cóipeáil go huathoibríoch. Roghnaigh an cód agus cóipeáil de láimh é.", cli: "Cóipeáil", copyCliCommand: "Cóipeáil ordú CLI (le haghaidh úsáide seachtraí / cúltaca)", connect: "Ceangail", diff --git a/web/src/i18n/hu.ts b/web/src/i18n/hu.ts index f09e3264ea9..beab21dcc8d 100644 --- a/web/src/i18n/hu.ts +++ b/web/src/i18n/hu.ts @@ -446,16 +446,19 @@ export const hu: Translations = { oauth: { title: "Szolgáltatói bejelentkezések (OAuth)", providerLogins: "Szolgáltatói bejelentkezések (OAuth)", - description: "{connected} / {total} OAuth-szolgáltató csatlakoztatva. A bejelentkezési folyamat jelenleg a CLI-n keresztül fut; kattintson a Parancs másolása gombra, és illessze be egy terminálba a beállításhoz.", + description: + "{connected} / {total} OAuth-szolgáltató csatlakoztatva. Használja a Bejelentkezés gombot az irányítópult által támogatott folyamatokhoz; a CLI-parancsok továbbra is elérhetők külső vagy tartalék beállításhoz.", connected: "Csatlakoztatva", expired: "Lejárt", - notConnected: "Nincs csatlakoztatva. Futtassa a {command} parancsot egy terminálban.", + notConnected: "Nincs csatlakoztatva. Használja a Bejelentkezés gombot, ha elérhető, vagy futtassa a {command} parancsot egy terminálban.", runInTerminal: "egy terminálban.", noProviders: "Nem észlelhető OAuth-képes szolgáltató.", login: "Bejelentkezés", disconnect: "Lecsatlakozás", managedExternally: "Külsőleg kezelt", copied: "Másolva ✓", + copyCode: "Kód másolása", + copyFailed: "Nem sikerült automatikusan másolni. Jelölje ki a kódot, és másolja kézzel.", cli: "Másolás", copyCliCommand: "CLI-parancs másolása (külső / tartalék)", connect: "Csatlakozás", diff --git a/web/src/i18n/it.ts b/web/src/i18n/it.ts index 927efa256c1..beb22f5f0df 100644 --- a/web/src/i18n/it.ts +++ b/web/src/i18n/it.ts @@ -446,16 +446,19 @@ export const it: Translations = { oauth: { title: "Accessi provider (OAuth)", providerLogins: "Accessi provider (OAuth)", - description: "{connected} di {total} provider OAuth connessi. I flussi di accesso vengono attualmente eseguiti tramite la CLI; clicca Copia comando e incolla in un terminale per configurare.", + description: + "{connected} di {total} provider OAuth connessi. Usa Accedi per i flussi supportati dalla dashboard; i comandi CLI restano disponibili per configurazioni esterne o di riserva.", connected: "Connesso", expired: "Scaduto", - notConnected: "Non connesso. Esegui {command} in un terminale.", + notConnected: "Non connesso. Usa Accedi se disponibile, oppure esegui {command} in un terminale.", runInTerminal: "in un terminale.", noProviders: "Nessun provider compatibile con OAuth rilevato.", login: "Accedi", disconnect: "Disconnetti", managedExternally: "Gestito esternamente", copied: "Copiato ✓", + copyCode: "Copia codice", + copyFailed: "Impossibile copiare automaticamente. Seleziona il codice e copialo manualmente.", cli: "Copia", copyCliCommand: "Copia comando CLI (per uso esterno / fallback)", connect: "Connetti", diff --git a/web/src/i18n/ja.ts b/web/src/i18n/ja.ts index 4de06a02ece..b3bb658a329 100644 --- a/web/src/i18n/ja.ts +++ b/web/src/i18n/ja.ts @@ -445,16 +445,19 @@ export const ja: Translations = { oauth: { title: "プロバイダーログイン (OAuth)", providerLogins: "プロバイダーログイン (OAuth)", - description: "{connected} / {total} OAuth プロバイダーが接続されています。ログインフローは現在 CLI 経由で実行されます。「コマンドをコピー」をクリックして、ターミナルに貼り付けてセットアップしてください。", + description: + "{connected} / {total} OAuth プロバイダーが接続されています。ダッシュボード対応のフローには「ログイン」を使用してください。外部またはフォールバック用のセットアップには引き続き CLI コマンドを利用できます。", connected: "接続済み", expired: "期限切れ", - notConnected: "未接続です。ターミナルで {command} を実行してください。", + notConnected: "未接続です。可能な場合は「ログイン」を使用するか、ターミナルで {command} を実行してください。", runInTerminal: "ターミナルで実行してください。", noProviders: "OAuth 対応プロバイダーは検出されませんでした。", login: "ログイン", disconnect: "切断", managedExternally: "外部で管理", copied: "コピーしました ✓", + copyCode: "コードをコピー", + copyFailed: "自動でコピーできませんでした。コードを選択して手動でコピーしてください。", cli: "コピー", copyCliCommand: "CLI コマンドをコピー (外部 / フォールバック用)", connect: "接続", diff --git a/web/src/i18n/ko.ts b/web/src/i18n/ko.ts index 6cd65f3133f..e4dc604c4cf 100644 --- a/web/src/i18n/ko.ts +++ b/web/src/i18n/ko.ts @@ -445,16 +445,19 @@ export const ko: Translations = { oauth: { title: "제공자 로그인 (OAuth)", providerLogins: "제공자 로그인 (OAuth)", - description: "{connected}/{total} OAuth 제공자가 연결되었습니다. 로그인 흐름은 현재 CLI를 통해 실행됩니다. 명령 복사를 클릭하고 터미널에 붙여넣어 설정하세요.", + description: + "{connected}/{total} OAuth 제공자가 연결되었습니다. 대시보드에서 지원되는 흐름에는 로그인을 사용하세요. 외부 또는 대체 설정에는 CLI 명령을 계속 사용할 수 있습니다.", connected: "연결됨", expired: "만료됨", - notConnected: "연결되지 않음. 터미널에서 {command}을(를) 실행하세요.", + notConnected: "연결되지 않음. 가능하면 로그인을 사용하거나 터미널에서 {command}을(를) 실행하세요.", runInTerminal: "터미널에서.", noProviders: "OAuth를 지원하는 제공자가 감지되지 않았습니다.", login: "로그인", disconnect: "연결 해제", managedExternally: "외부에서 관리됨", copied: "복사됨 ✓", + copyCode: "코드 복사", + copyFailed: "자동으로 복사할 수 없습니다. 코드를 선택하여 직접 복사하세요.", cli: "복사", copyCliCommand: "CLI 명령 복사 (외부 / 대체용)", connect: "연결", diff --git a/web/src/i18n/pt.ts b/web/src/i18n/pt.ts index 90b5ea42355..7e2513f4a9e 100644 --- a/web/src/i18n/pt.ts +++ b/web/src/i18n/pt.ts @@ -447,16 +447,19 @@ export const pt: Translations = { oauth: { title: "Inícios de sessão de fornecedor (OAuth)", providerLogins: "Inícios de sessão de fornecedor (OAuth)", - description: "{connected} de {total} fornecedores OAuth ligados. Os fluxos de início de sessão são executados via CLI; clique em Copiar comando e cole num terminal para configurar.", + description: + "{connected} de {total} fornecedores OAuth ligados. Use Iniciar sessão para fluxos suportados pelo painel; os comandos CLI continuam disponíveis para configuração externa ou de recurso.", connected: "Ligado", expired: "Expirado", - notConnected: "Não ligado. Execute {command} num terminal.", + notConnected: "Não ligado. Use Iniciar sessão quando disponível, ou execute {command} num terminal.", runInTerminal: "num terminal.", noProviders: "Não foram detetados fornecedores compatíveis com OAuth.", login: "Iniciar sessão", disconnect: "Desligar", managedExternally: "Gerido externamente", copied: "Copiado ✓", + copyCode: "Copiar código", + copyFailed: "Não foi possível copiar automaticamente. Selecione o código e copie-o manualmente.", cli: "Copiar", copyCliCommand: "Copiar comando CLI (para externo / fallback)", connect: "Ligar", diff --git a/web/src/i18n/ru.ts b/web/src/i18n/ru.ts index c133f0398e9..c8db540f11d 100644 --- a/web/src/i18n/ru.ts +++ b/web/src/i18n/ru.ts @@ -446,16 +446,19 @@ export const ru: Translations = { oauth: { title: "Входы провайдеров (OAuth)", providerLogins: "Входы провайдеров (OAuth)", - description: "Подключено {connected} из {total} OAuth-провайдеров. Процесс входа в настоящее время выполняется через CLI; нажмите «Скопировать команду» и вставьте в терминал для настройки.", + description: + "Подключено {connected} из {total} OAuth-провайдеров. Используйте «Войти» для процессов, поддерживаемых панелью; команды CLI остаются доступными для внешней или резервной настройки.", connected: "Подключено", expired: "Срок истёк", - notConnected: "Не подключено. Выполните {command} в терминале.", + notConnected: "Не подключено. Используйте «Войти», если доступно, или выполните {command} в терминале.", runInTerminal: "в терминале.", noProviders: "OAuth-совместимые провайдеры не обнаружены.", login: "Войти", disconnect: "Отключить", managedExternally: "Управляется извне", copied: "Скопировано ✓", + copyCode: "Скопировать код", + copyFailed: "Не удалось скопировать автоматически. Выделите код и скопируйте его вручную.", cli: "Копировать", copyCliCommand: "Скопировать CLI-команду (для внешнего / резервного варианта)", connect: "Подключить", diff --git a/web/src/i18n/tr.ts b/web/src/i18n/tr.ts index e23dad98d8f..dc9a62894b1 100644 --- a/web/src/i18n/tr.ts +++ b/web/src/i18n/tr.ts @@ -446,16 +446,19 @@ export const tr: Translations = { oauth: { title: "Sağlayıcı Girişleri (OAuth)", providerLogins: "Sağlayıcı Girişleri (OAuth)", - description: "{connected}/{total} OAuth sağlayıcısı bağlandı. Giriş akışları şu anda CLI üzerinden çalışır; Komutu kopyala'ya tıklayın ve kurmak için bir terminale yapıştırın.", + description: + "{connected}/{total} OAuth sağlayıcısı bağlandı. Panel destekli akışlar için Giriş'i kullanın; CLI komutları harici veya yedek kurulum için kullanılabilir.", connected: "Bağlandı", expired: "Süresi doldu", - notConnected: "Bağlı değil. Bir terminalde {command} komutunu çalıştırın.", + notConnected: "Bağlı değil. Mümkünse Giriş'i kullanın veya bir terminalde {command} komutunu çalıştırın.", runInTerminal: "bir terminalde.", noProviders: "OAuth uyumlu sağlayıcı algılanmadı.", login: "Giriş", disconnect: "Bağlantıyı kes", managedExternally: "Harici olarak yönetiliyor", copied: "Kopyalandı ✓", + copyCode: "Kodu kopyala", + copyFailed: "Otomatik olarak kopyalanamadı. Kodu seçip elle kopyalayın.", cli: "Kopyala", copyCliCommand: "CLI komutunu kopyala (harici / yedek için)", connect: "Bağlan", diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index a93f921cadf..6a888226adb 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -530,6 +530,8 @@ export interface Translations { disconnect: string; managedExternally: string; copied: string; + copyCode: string; + copyFailed: string; cli: string; copyCliCommand: string; connect: string; diff --git a/web/src/i18n/uk.ts b/web/src/i18n/uk.ts index 8c329b731c3..c8c5c28787c 100644 --- a/web/src/i18n/uk.ts +++ b/web/src/i18n/uk.ts @@ -447,16 +447,19 @@ export const uk: Translations = { oauth: { title: "Входи постачальників (OAuth)", providerLogins: "Входи постачальників (OAuth)", - description: "Підключено {connected} з {total} постачальників OAuth. Процеси входу наразі виконуються через CLI; натисніть «Скопіювати команду» та вставте у термінал, щоб налаштувати.", + description: + "Підключено {connected} з {total} постачальників OAuth. Використовуйте «Увійти» для процесів, підтримуваних панеллю; команди CLI залишаються доступними для зовнішнього або резервного налаштування.", connected: "Підключено", expired: "Прострочено", - notConnected: "Не підключено. Виконайте {command} у терміналі.", + notConnected: "Не підключено. Використовуйте «Увійти», якщо доступно, або виконайте {command} у терміналі.", runInTerminal: "у терміналі.", noProviders: "Не виявлено постачальників із підтримкою OAuth.", login: "Увійти", disconnect: "Відключити", managedExternally: "Керується ззовні", copied: "Скопійовано ✓", + copyCode: "Скопіювати код", + copyFailed: "Не вдалося скопіювати автоматично. Виділіть код і скопіюйте його вручну.", cli: "Копіювати", copyCliCommand: "Скопіювати CLI-команду (для зовнішнього / резервного варіанту)", connect: "Підключити", diff --git a/web/src/i18n/zh-hant.ts b/web/src/i18n/zh-hant.ts index c4ec4af3e77..eeb72988ca1 100644 --- a/web/src/i18n/zh-hant.ts +++ b/web/src/i18n/zh-hant.ts @@ -445,16 +445,19 @@ export const zhHant: Translations = { oauth: { title: "提供者登入(OAuth)", providerLogins: "提供者登入(OAuth)", - description: "已連線 {connected}/{total} 個 OAuth 提供者。登入流程目前透過 CLI 執行;請點擊「複製指令」並貼到終端機完成設定。", + description: + "已連線 {connected}/{total} 個 OAuth 提供者。儀表板支援的流程請使用「登入」;CLI 指令仍可用於外部或備用設定。", connected: "已連線", expired: "已過期", - notConnected: "未連線。請在終端機執行 {command}。", + notConnected: "未連線。可用時請使用「登入」,或在終端機執行 {command}。", runInTerminal: "於終端機。", noProviders: "未偵測到支援 OAuth 的提供者。", login: "登入", disconnect: "中斷連線", managedExternally: "由外部管理", copied: "已複製 ✓", + copyCode: "複製代碼", + copyFailed: "無法自動複製。請選取代碼並手動複製。", cli: "複製", copyCliCommand: "複製 CLI 指令(外部 / 備援用)", connect: "連線", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b34f3341a42..4cf821c85aa 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -440,16 +440,19 @@ export const zh: Translations = { oauth: { title: "提供商登录(OAuth)", providerLogins: "提供商登录(OAuth)", - description: "已连接 {connected}/{total} 个 OAuth 提供商。登录流程目前通过 CLI 运行;点击「复制命令」并粘贴到终端中进行设置。", + description: + "已连接 {connected}/{total} 个 OAuth 提供商。仪表板支持的流程请使用「登录」;CLI 命令仍可用于外部或备用设置。", connected: "已连接", expired: "已过期", - notConnected: "未连接。在终端中运行 {command}。", + notConnected: "未连接。可用时请使用「登录」,或在终端中运行 {command}。", runInTerminal: "在终端中。", noProviders: "未检测到支持 OAuth 的提供商。", login: "登录", disconnect: "断开连接", managedExternally: "外部管理", copied: "已复制 ✓", + copyCode: "复制代码", + copyFailed: "无法自动复制。请选中代码并手动复制。", cli: "复制", copyCliCommand: "复制 CLI 命令(用于外部/备用方式)", connect: "连接", diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index 4da9234ac5d..4d63d51a01a 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -2,21 +2,28 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { api } from "./api"; +const SESSION_HEADER = "X-Hermes-Session-Token"; + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); +function jsonFetchMock(body: unknown = { ok: true }) { + return vi.fn( + async () => + new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); +} + describe("api.getModelOptions", () => { it("requests a live model refresh when asked", async () => { vi.stubGlobal("window", {}); - const fetchMock = vi.fn(async () => - new Response(JSON.stringify({ providers: [] }), { - headers: { "Content-Type": "application/json" }, - status: 200, - }), - ); + const fetchMock = jsonFetchMock({ providers: [] }); vi.stubGlobal("fetch", fetchMock); await api.getModelOptions({ refresh: true }); @@ -30,12 +37,7 @@ describe("api.getModelOptions", () => { it("keeps explicit profile scoping when refreshing", async () => { vi.stubGlobal("window", {}); - const fetchMock = vi.fn(async () => - new Response(JSON.stringify({ providers: [] }), { - headers: { "Content-Type": "application/json" }, - status: 200, - }), - ); + const fetchMock = jsonFetchMock({ providers: [] }); vi.stubGlobal("fetch", fetchMock); await api.getModelOptions({ profile: "default", refresh: true }); @@ -46,3 +48,59 @@ describe("api.getModelOptions", () => { ); }); }); + +describe("api OAuth helpers", () => { + it("starts OAuth login in gated mode without requiring an injected session token", async () => { + vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true }); + const fetchMock = jsonFetchMock({ + flow: "device_code", + session_id: "oauth-session", + }); + vi.stubGlobal("fetch", fetchMock); + + await api.startOAuthLogin("openai-codex"); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/providers/oauth/openai-codex/start", + expect.objectContaining({ + body: "{}", + credentials: "include", + method: "POST", + }), + ); + const headers = fetchMock.mock.calls[0][1]?.headers as Headers; + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.has(SESSION_HEADER)).toBe(false); + }); + + it("still sends the injected session token for OAuth login in loopback mode", async () => { + vi.stubGlobal("window", { __HERMES_SESSION_TOKEN__: "loopback-token" }); + const fetchMock = jsonFetchMock({ + flow: "device_code", + session_id: "oauth-session", + }); + vi.stubGlobal("fetch", fetchMock); + + await api.startOAuthLogin("openai-codex"); + + const headers = fetchMock.mock.calls[0][1]?.headers as Headers; + expect(headers.get(SESSION_HEADER)).toBe("loopback-token"); + }); + + it("runs provider auth mutations in gated mode via cookie auth", async () => { + vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true }); + const fetchMock = jsonFetchMock({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + await api.disconnectOAuthProvider("anthropic"); + await api.submitOAuthCode("anthropic", "oauth-session", "code-123"); + await api.cancelOAuthSession("oauth-session"); + await api.revealEnvVar("OPENAI_API_KEY"); + + for (const call of fetchMock.mock.calls) { + const init = call[1] as RequestInit; + expect(init.credentials).toBe("include"); + expect((init.headers as Headers).has(SESSION_HEADER)).toBe(false); + } + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e6320812982..1adcd184e3a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -34,7 +34,6 @@ declare global { __HERMES_AUTH_REQUIRED__?: boolean; } } -let _sessionToken: string | null = null; const SESSION_HEADER = "X-Hermes-Session-Token"; function setSessionHeader(headers: Headers, token: string): void { @@ -197,16 +196,6 @@ function pluginPath(name: string): string { return name.split("/").map(encodeURIComponent).join("/"); } -async function getSessionToken(): Promise { - if (_sessionToken) return _sessionToken; - const injected = window.__HERMES_SESSION_TOKEN__; - if (injected) { - _sessionToken = injected; - return _sessionToken; - } - throw new Error("Session token not available — page must be served by the Hermes dashboard server"); -} - /** * Fetch a single-use ticket for a WebSocket upgrade in gated mode. * @@ -414,6 +403,15 @@ export const api = { fetchJSON(appendProfileParam("/api/sessions/stats", profile)), exportSessionUrl: (id: string, profile = getManagementProfile()) => appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/export`, profile), + importSessions: ( + sessions: Array>, + profile = getManagementProfile(), + ) => + fetchJSON("/api/sessions/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessions, profile: profile || undefined }), + }), pruneSessions: ( older_than_days: number, source?: string, @@ -553,17 +551,12 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }), }), - revealEnvVar: async (key: string) => { - const token = await getSessionToken(); - return fetchJSON<{ key: string; value: string }>("/api/env/reveal", { + revealEnvVar: (key: string) => + fetchJSON<{ key: string; value: string }>("/api/env/reveal", { method: "POST", - headers: { - "Content-Type": "application/json", - [SESSION_HEADER]: token, - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }), - }); - }, + }), // Cron jobs getCronJobs: (profile = "all") => @@ -788,58 +781,42 @@ export const api = { // OAuth provider management getOAuthProviders: () => fetchJSON("/api/providers/oauth"), - disconnectOAuthProvider: async (providerId: string) => { - const token = await getSessionToken(); - return fetchJSON<{ ok: boolean; provider: string }>( + disconnectOAuthProvider: (providerId: string) => + fetchJSON<{ ok: boolean; provider: string }>( `/api/providers/oauth/${encodeURIComponent(providerId)}`, { method: "DELETE", - headers: { [SESSION_HEADER]: token }, }, - ); - }, - startOAuthLogin: async (providerId: string) => { - const token = await getSessionToken(); - return fetchJSON( + ), + startOAuthLogin: (providerId: string) => + fetchJSON( `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, { method: "POST", - headers: { - "Content-Type": "application/json", - [SESSION_HEADER]: token, - }, + headers: { "Content-Type": "application/json" }, body: "{}", }, - ); - }, - submitOAuthCode: async (providerId: string, sessionId: string, code: string) => { - const token = await getSessionToken(); - return fetchJSON( + ), + submitOAuthCode: (providerId: string, sessionId: string, code: string) => + fetchJSON( `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, { method: "POST", - headers: { - "Content-Type": "application/json", - [SESSION_HEADER]: token, - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId, code }), }, - ); - }, + ), pollOAuthSession: (providerId: string, sessionId: string) => fetchJSON( `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`, ), - cancelOAuthSession: async (sessionId: string) => { - const token = await getSessionToken(); - return fetchJSON<{ ok: boolean }>( + cancelOAuthSession: (sessionId: string) => + fetchJSON<{ ok: boolean }>( `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE", - headers: { [SESSION_HEADER]: token }, }, - ); - }, + ), // Messaging platforms (gateway channels) getMessagingPlatforms: () => @@ -1330,6 +1307,16 @@ export interface SessionStoreStats { by_source: Record; } +export interface SessionImportResponse { + ok: boolean; + imported: number; + skipped: number; + detached: number; + imported_ids: string[]; + skipped_ids: string[]; + errors: Array>; +} + export interface SkillHubResult { name: string; description: string; diff --git a/web/src/lib/chatImagePaste.test.ts b/web/src/lib/chatImagePaste.test.ts new file mode 100644 index 00000000000..047dec17528 --- /dev/null +++ b/web/src/lib/chatImagePaste.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { + firstImageFromClipboard, + imageFilesFromTransfer, + transferMayContainImage, +} from "./chatImagePaste"; + +// Minimal DataTransfer stand-ins. jsdom's DataTransfer doesn't let us seed +// items/files, so we hand-roll the shape the helpers read. +function makeItem(kind: string, type: string, file: File | null) { + return { kind, type, getAsFile: () => file } as unknown as DataTransferItem; +} + +function makeData(opts: { + items?: DataTransferItem[]; + files?: File[]; +}): DataTransfer { + const items = opts.items ?? []; + const files = opts.files ?? []; + const itemList: Record = { length: items.length }; + items.forEach((it, i) => { + itemList[i] = it; + }); + const fileList: Record = { length: files.length }; + files.forEach((f, i) => { + fileList[i] = f; + }); + return { + items: itemList, + files: fileList, + } as unknown as DataTransfer; +} + +const png = new File([new Uint8Array([1, 2, 3])], "x.png", { + type: "image/png", +}); +const gif = new File([new Uint8Array([4, 5])], "y.gif", { + type: "image/gif", +}); + +describe("firstImageFromClipboard", () => { + it("returns null for null clipboard data", () => { + expect(firstImageFromClipboard(null)).toBeNull(); + }); + + it("finds an image via items[].getAsFile()", () => { + const data = makeData({ items: [makeItem("file", "image/png", png)] }); + expect(firstImageFromClipboard(data)).toBe(png); + }); + + it("ignores non-file and non-image items", () => { + const data = makeData({ + items: [ + makeItem("string", "text/plain", null), + makeItem("file", "application/pdf", new File([], "a.pdf")), + ], + }); + expect(firstImageFromClipboard(data)).toBeNull(); + }); + + it("falls back to files[] when items are absent (Safari/Firefox)", () => { + const data = makeData({ files: [png] }); + expect(firstImageFromClipboard(data)).toBe(png); + }); + + it("returns null when nothing image-like is present", () => { + const data = makeData({ + files: [new File([], "notes.txt", { type: "text/plain" })], + }); + expect(firstImageFromClipboard(data)).toBeNull(); + }); +}); + +describe("imageFilesFromTransfer", () => { + it("dedupes the same file when present in both items and files", () => { + const data = makeData({ + items: [makeItem("file", "image/png", png)], + files: [png, gif], + }); + expect(imageFilesFromTransfer(data)).toEqual([png, gif]); + }); +}); + +describe("transferMayContainImage", () => { + it("is true for image items even when type is empty (some browsers)", () => { + const data = makeData({ + items: [makeItem("file", "", png)], + }); + expect(transferMayContainImage(data)).toBe(true); + }); + + it("is false for text-only transfers", () => { + const data = makeData({ + items: [makeItem("string", "text/plain", null)], + }); + expect(transferMayContainImage(data)).toBe(false); + }); +}); diff --git a/web/src/lib/chatImagePaste.ts b/web/src/lib/chatImagePaste.ts new file mode 100644 index 00000000000..3767cf80374 --- /dev/null +++ b/web/src/lib/chatImagePaste.ts @@ -0,0 +1,164 @@ +import { authedFetch } from "@/lib/api"; + +// Clipboard image MIME → file extension. Mirrors the set the TUI's /image +// attach path and the gateway's image sniffer accept. +const IMAGE_MIME_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", +}; + +// Anthropic caps a single vision image at ~25 MB; reject earlier client-side +// with a clear message rather than round-tripping a doomed upload. +const MAX_IMAGE_BYTES = 25 * 1024 * 1024; + +export interface ChatImageUploadResult { + /** Absolute path under HERMES_HOME/images the gateway wrote. */ + path: string; + /** Byte size of the uploaded image. */ + bytes: number; + /** Basename written on the server. */ + name: string; + mime_type: string; +} + +function imageFileKey(file: File): string { + return `${file.name}\0${file.type}\0${file.size}\0${file.lastModified}`; +} + +function addImageFile(files: File[], seen: Set, file: File | null) { + if (!file || !file.type.startsWith("image/")) return; + const key = imageFileKey(file); + if (seen.has(key)) return; + seen.add(key); + files.push(file); +} + +/** Pull every image file out of a DataTransfer (clipboard or drop). */ +export function imageFilesFromTransfer( + data: DataTransfer | null, +): File[] { + if (!data) return []; + const files: File[] = []; + const seen = new Set(); + + if (data.items?.length) { + for (let i = 0; i < data.items.length; i++) { + const item = data.items[i]; + if (item.kind === "file" && item.type.startsWith("image/")) { + addImageFile(files, seen, item.getAsFile()); + } + } + } + + if (data.files?.length) { + for (let i = 0; i < data.files.length; i++) { + addImageFile(files, seen, data.files[i]); + } + } + + return files; +} + +/** Pull the first image blob out of a DataTransfer, or null if none present. */ +export function firstImageFromClipboard( + data: DataTransfer | null, +): File | null { + return imageFilesFromTransfer(data)[0] ?? null; +} + +/** True when a drag payload may contain an image (for dragover preventDefault). */ +export function transferMayContainImage(data: DataTransfer | null): boolean { + if (!data) return false; + if (data.items?.length) { + for (let i = 0; i < data.items.length; i++) { + const item = data.items[i]; + if ( + item.kind === "file" && + (!item.type || item.type.startsWith("image/")) + ) { + return true; + } + } + return false; + } + if (data.files?.length) { + for (let i = 0; i < data.files.length; i++) { + if (data.files[i].type.startsWith("image/")) return true; + } + } + return false; +} + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => + reject(reader.error ?? new Error("image read failed")); + reader.onload = () => { + const result = reader.result; + if (typeof result === "string") { + resolve(result); + } else { + reject(new Error("image read failed")); + } + }; + reader.readAsDataURL(file); + }); +} + +/** + * Upload a browser clipboard/drop image to ``HERMES_HOME/images`` via the + * dedicated chat upload endpoint and return the absolute gateway path. + * + * The dashboard Chat tab is an xterm mirror of a TUI running INSIDE the + * gateway. The container has no access to the browser's clipboard, so the + * server-side ``clipboard.paste`` path can never see a pasted image. + * Upload the bytes the browser already holds, then hand the path to the + * TUI's ``/image`` command. + */ +export async function uploadChatImage( + blob: Blob, + profile = "", +): Promise { + if (blob.size === 0) throw new Error("clipboard image is empty"); + if (blob.size > MAX_IMAGE_BYTES) { + const mb = Math.round(MAX_IMAGE_BYTES / (1024 * 1024)); + throw new Error(`image too large (max ${mb} MB)`); + } + + const mime = blob.type || "image/png"; + const ext = IMAGE_MIME_EXT[mime] || "png"; + const filename = + blob instanceof File && blob.name + ? blob.name + : `clipboard.${ext}`; + const file = + blob instanceof File + ? blob + : new File([blob], filename, { type: mime }); + + const dataUrl = await fileToDataUrl(file); + const qs = profile ? `?profile=${encodeURIComponent(profile)}` : ""; + const res = await authedFetch(`/api/chat/image-upload${qs}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + data_url: dataUrl, + filename, + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(text || `HTTP ${res.status}`); + } + + const uploaded = (await res.json()) as ChatImageUploadResult; + if (!uploaded?.path) { + throw new Error("image upload did not return a path"); + } + return uploaded; +} diff --git a/web/src/lib/clipboard.test.ts b/web/src/lib/clipboard.test.ts new file mode 100644 index 00000000000..8f6fd1fba21 --- /dev/null +++ b/web/src/lib/clipboard.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { copyTextToClipboard } from "./clipboard"; + +const originalNavigator = globalThis.navigator; +const originalDocument = globalThis.document; +const originalWindow = globalThis.window; + +function setGlobal( + key: K, + value: (typeof globalThis)[K] | undefined, +) { + Object.defineProperty(globalThis, key, { + configurable: true, + value, + }); +} + +afterEach(() => { + setGlobal("navigator", originalNavigator); + setGlobal("document", originalDocument); + setGlobal("window", originalWindow); + vi.restoreAllMocks(); +}); + +describe("copyTextToClipboard", () => { + it("uses navigator.clipboard when available", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + setGlobal( + "navigator", + { clipboard: { writeText } } as unknown as Navigator, + ); + setGlobal("document", undefined); + + await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith("CODEX-1234"); + }); + + it("falls back to selection copy when Clipboard API fails", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("not allowed")); + const appendChild = vi.fn(); + const removeChild = vi.fn(); + const execCommand = vi.fn().mockReturnValue(true); + const textarea = { + focus: vi.fn(), + select: vi.fn(), + setAttribute: vi.fn(), + setSelectionRange: vi.fn(), + style: {}, + value: "", + } as unknown as HTMLTextAreaElement; + + setGlobal( + "navigator", + { clipboard: { writeText } } as unknown as Navigator, + ); + setGlobal("document", { + body: { appendChild, removeChild }, + createElement: vi.fn(() => textarea), + execCommand, + getSelection: vi.fn(() => null), + } as unknown as Document); + + await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true); + + expect(writeText).toHaveBeenCalledWith("CODEX-1234"); + expect(textarea.value).toBe("CODEX-1234"); + expect(appendChild).toHaveBeenCalledWith(textarea); + expect(textarea.select).toHaveBeenCalled(); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(removeChild).toHaveBeenCalledWith(textarea); + }); + + it("uses selection copy directly in insecure browser contexts", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const appendChild = vi.fn(); + const removeChild = vi.fn(); + const execCommand = vi.fn().mockReturnValue(true); + const textarea = { + focus: vi.fn(), + select: vi.fn(), + setAttribute: vi.fn(), + setSelectionRange: vi.fn(), + style: {}, + value: "", + } as unknown as HTMLTextAreaElement; + + setGlobal( + "navigator", + { clipboard: { writeText } } as unknown as Navigator, + ); + setGlobal( + "window", + { isSecureContext: false } as unknown as Window & typeof globalThis, + ); + setGlobal("document", { + body: { appendChild, removeChild }, + createElement: vi.fn(() => textarea), + execCommand, + getSelection: vi.fn(() => null), + } as unknown as Document); + + await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true); + + expect(writeText).not.toHaveBeenCalled(); + expect(execCommand).toHaveBeenCalledWith("copy"); + }); + + it("returns false when no copy mechanism is available", async () => { + setGlobal("navigator", {} as Navigator); + setGlobal("document", undefined); + + await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(false); + }); +}); diff --git a/web/src/lib/clipboard.ts b/web/src/lib/clipboard.ts new file mode 100644 index 00000000000..749168e8e5d --- /dev/null +++ b/web/src/lib/clipboard.ts @@ -0,0 +1,56 @@ +export async function copyTextToClipboard(text: string): Promise { + const clipboard = + typeof navigator === "undefined" ? undefined : navigator.clipboard; + const secureContext = + typeof window === "undefined" ? true : window.isSecureContext; + if (secureContext && clipboard?.writeText) { + try { + await clipboard.writeText(text); + return true; + } catch { + // Fall through to the selection-based copy path below. + } + } + + if (typeof document === "undefined") { + return false; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + textarea.style.left = "-1000px"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + + const selection = document.getSelection(); + const ranges: Range[] = []; + if (selection) { + for (let i = 0; i < selection.rangeCount; i += 1) { + ranges.push(selection.getRangeAt(i)); + } + } + + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + let copied = false; + try { + copied = document.execCommand("copy"); + } catch { + copied = false; + } finally { + document.body.removeChild(textarea); + if (selection) { + selection.removeAllRanges(); + for (const range of ranges) { + selection.addRange(range); + } + } + } + + return copied; +} diff --git a/web/src/lib/pty-mobile-input.test.ts b/web/src/lib/pty-mobile-input.test.ts new file mode 100644 index 00000000000..5245a2e3186 --- /dev/null +++ b/web/src/lib/pty-mobile-input.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizePtyMobileInput, + shouldTreatInputAsMobileReplacement, + updatePtyInputLine, +} from "./pty-mobile-input"; + +describe("shouldTreatInputAsMobileReplacement", () => { + it("recognizes explicit browser replacement input", () => { + expect(shouldTreatInputAsMobileReplacement("insertReplacementText", "Kain", false)).toBe(true); + expect(shouldTreatInputAsMobileReplacement("insertFromComposition", "Kain", false)).toBe(true); + expect(shouldTreatInputAsMobileReplacement("insertCompositionText", "Kain", false)).toBe(true); + }); + + it("treats multi-character mobile insertText as replacement-like", () => { + expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", true)).toBe(true); + expect(shouldTreatInputAsMobileReplacement("insertText", "K", true)).toBe(false); + expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", false)).toBe(false); + }); +}); + +describe("normalizePtyMobileInput", () => { + it("turns a Gboard full-line suggestion into a line replacement", () => { + const result = normalizePtyMobileInput( + "hello my name is Kain Kain", + "hello my name is kain", + true, + ); + + expect(result.normalized).toBe(true); + expect(result.nextLine).toBe("hello my name is Kain"); + expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain"); + }); + + it("turns a Gboard last-word suggestion into a last-word replacement", () => { + const result = normalizePtyMobileInput("Kain", "hello my name is kain", true); + + expect(result.normalized).toBe(true); + expect(result.nextLine).toBe("hello my name is Kain"); + expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain"); + }); + + it("does not normalize ordinary appends when replacement is not active", () => { + const result = normalizePtyMobileInput( + "hello my name is Kain Kain", + "hello my name is kain", + false, + ); + + expect(result.normalized).toBe(false); + expect(result.nextLine).toBe("hello my name is kainhello my name is Kain Kain"); + }); + + it("does not normalize control input", () => { + const result = normalizePtyMobileInput("\r", "hello", true); + + expect(result.normalized).toBe(false); + expect(result.nextLine).toBe(""); + expect(result.data).toBe("\r"); + }); + + it("does not collapse legitimate single-letter reduplication", () => { + // "a a" is a plausible thing to type; the >=2-char guard keeps the + // duplicate-final-word collapse from eating it inside the window. + const result = normalizePtyMobileInput("a a", "a", true); + + expect(result.normalized).toBe(false); + expect(result.data).toBe("a a"); + }); +}); + +describe("updatePtyInputLine", () => { + it("tracks printable text, delete, and submit", () => { + expect(updatePtyInputLine("", "abc")).toBe("abc"); + expect(updatePtyInputLine("abc", "\x7f")).toBe("ab"); + expect(updatePtyInputLine("abc", "\r")).toBe(""); + }); + + it("resets tracking on escape sequences instead of appending their payload", () => { + // Left-arrow arrives as one CSI chunk; the tracker cannot model cursor + // moves, so it must disarm rather than record "hello[D". + expect(updatePtyInputLine("hello", "\x1b[D")).toBe(""); + expect(updatePtyInputLine("hello", "\x1b[H")).toBe(""); + expect(updatePtyInputLine("hello", "\x1bOP")).toBe(""); + }); +}); + +describe("normalizePtyMobileInput after cursor movement", () => { + it("does not emit a replacement against a tracker reset by arrow keys", () => { + // Simulate: type "hello my name is kain", press left-arrow, then a + // Gboard suggestion arrives. The tracker reset means no replacement + // heuristic can fire against a stale line snapshot. + const afterArrow = updatePtyInputLine("hello my name is kain", "\x1b[D"); + const result = normalizePtyMobileInput("Kain", afterArrow, true); + + expect(result.normalized).toBe(false); + expect(result.data).toBe("Kain"); + }); +}); diff --git a/web/src/lib/pty-mobile-input.ts b/web/src/lib/pty-mobile-input.ts new file mode 100644 index 00000000000..d6eb024101a --- /dev/null +++ b/web/src/lib/pty-mobile-input.ts @@ -0,0 +1,134 @@ +const DELETE = "\x7f"; + +// How long (ms) after a mobile IME / replacement event we treat subsequent +// terminal input as a candidate line-replacement rather than a plain append. +// Exported so the ChatPage integration and tests share one tunable value. +export const MOBILE_REPLACEMENT_WINDOW_MS = 350; + +function chars(text: string): string[] { + return Array.from(text); +} + +function removeLastChar(text: string): string { + const c = chars(text); + c.pop(); + return c.join(""); +} + +function isPlainText(data: string): boolean { + return !/[\x00-\x1f\x7f]/.test(data); +} + +function lastWordMatch(line: string): RegExpMatchArray | null { + return line.match(/^(.*?)(\S+)(\s*)$/u); +} + +function collapseDuplicatedFinalWord(text: string, previousLine: string): string { + const match = text.match(/^(.*?)(\S+)(\s+)(\S+)(\s*)$/u); + if (!match) return text; + + const [, prefix, first, , second, trailing] = match; + if (first.toLocaleLowerCase() !== second.toLocaleLowerCase()) return text; + // Only collapse a duplication the tracked line already ended with — i.e. + // Gboard re-emitted the final word. Requiring a >=2-char word avoids + // eating legitimate single-letter reduplication ("a a", "i i") that a + // user may genuinely type inside the replacement window. + if (first.length < 2) return text; + if (!previousLine.trimEnd().toLocaleLowerCase().endsWith(first.toLocaleLowerCase())) { + return text; + } + return `${prefix}${first}${trailing}`; +} + +function replacementLineForMobileInput( + currentLine: string, + incoming: string, +): string | null { + if (!currentLine || currentLine.length < 2 || !incoming) return null; + + const currentLower = currentLine.toLocaleLowerCase(); + const incomingLower = incoming.toLocaleLowerCase(); + + if (incomingLower.startsWith(currentLower)) { + return collapseDuplicatedFinalWord(incoming, currentLine); + } + + const word = lastWordMatch(currentLine); + if (!word) return null; + + const [, prefix, last, trailing] = word; + if (trailing) return null; + + const incomingFirst = incoming.trimStart().split(/\s+/u)[0] ?? ""; + if ( + incomingFirst && + incomingFirst.toLocaleLowerCase() === last.toLocaleLowerCase() + ) { + return `${prefix}${collapseDuplicatedFinalWord(incoming, currentLine)}`; + } + + return null; +} + +export function shouldTreatInputAsMobileReplacement( + inputType: string | undefined, + data: string | null | undefined, + isMobileLike: boolean, +): boolean { + if ( + inputType === "insertReplacementText" || + inputType === "insertFromComposition" || + inputType === "insertCompositionText" + ) { + return true; + } + return isMobileLike && inputType === "insertText" && (data?.length ?? 0) > 1; +} + +export function updatePtyInputLine(currentLine: string, data: string): string { + // Escape sequences (arrow keys, home/end, function keys, paste guards) + // move the cursor or edit the line in ways this flat tracker cannot + // model — and the per-char loop below would append their printable + // payload (e.g. the "[D" of a left-arrow) as if it were typed text. + // Reset instead: an unknown cursor position must disarm replacement + // normalization until the user starts a fresh, cleanly-tracked line. + if (data.includes("\x1b")) { + return ""; + } + let next = currentLine; + for (const ch of chars(data)) { + if (ch === "\r" || ch === "\n") { + next = ""; + } else if (ch === DELETE || ch === "\b") { + next = removeLastChar(next); + } else if (ch === "\x15") { + next = ""; + } else if (isPlainText(ch)) { + next += ch; + } + } + return next; +} + +export function normalizePtyMobileInput( + data: string, + currentLine: string, + replacementActive: boolean, +): { data: string; nextLine: string; normalized: boolean } { + if (replacementActive && isPlainText(data)) { + const replacementLine = replacementLineForMobileInput(currentLine, data); + if (replacementLine !== null) { + return { + data: DELETE.repeat(chars(currentLine).length) + replacementLine, + nextLine: replacementLine, + normalized: true, + }; + } + } + + return { + data, + nextLine: updatePtyInputLine(currentLine, data), + normalized: false, + }; +} diff --git a/web/src/lib/pty-reconnect.test.ts b/web/src/lib/pty-reconnect.test.ts new file mode 100644 index 00000000000..48543a65402 --- /dev/null +++ b/web/src/lib/pty-reconnect.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { + shouldBlockPtyInput, + shouldReconnectPtyOnPageResume, +} from "./pty-reconnect"; + +describe("shouldReconnectPtyOnPageResume", () => { + it("reconnects a missing socket when the active page becomes visible", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: null, + ptyState: "reconnecting", + }), + ).toBe(true); + }); + + it("reconnects closed or closing sockets on visible resume", () => { + for (const socketReadyState of [2, 3]) { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState, + ptyState: "reconnecting", + }), + ).toBe(true); + } + }); + + it("does not reconnect an open socket on visible resume", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: 1, + ptyState: "open", + }), + ).toBe(false); + }); + + it("reconnects a still-connecting socket when the page is already in reconnecting state", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: 0, + ptyState: "reconnecting", + }), + ).toBe(true); + }); + + it("does not reconnect while the page is hidden", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "hidden", + online: true, + socketReadyState: 3, + ptyState: "reconnecting", + }), + ).toBe(false); + }); + + it("defers reconnect while offline", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: false, + socketReadyState: 3, + ptyState: "reconnecting", + }), + ).toBe(false); + }); + + it("does not fire a redundant reconnect while a connect is in flight (wsRef not yet assigned)", () => { + // The async socket-open IIFE has begun but not yet assigned wsRef, so + // socketReadyState reads null. Without the connectInFlight guard this + // would return true and double-connect. + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: null, + ptyState: "connecting", + connectInFlight: true, + }), + ).toBe(false); + }); + + it("still reconnects an in-flight connect when the page already believes it is closed", () => { + // A stuck attempt the user is actively trying to recover (manual reconnect + // or a closed state) must not be suppressed by the in-flight guard. + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: null, + ptyState: "closed", + connectInFlight: true, + }), + ).toBe(true); + }); +}); + +describe("shouldBlockPtyInput", () => { + it("allows input only while the PTY socket is open", () => { + expect(shouldBlockPtyInput("open")).toBe(false); + expect(shouldBlockPtyInput("connecting")).toBe(true); + expect(shouldBlockPtyInput("reconnecting")).toBe(true); + expect(shouldBlockPtyInput("closed")).toBe(true); + expect(shouldBlockPtyInput("ended")).toBe(true); + }); +}); diff --git a/web/src/lib/pty-reconnect.ts b/web/src/lib/pty-reconnect.ts new file mode 100644 index 00000000000..8af050493dd --- /dev/null +++ b/web/src/lib/pty-reconnect.ts @@ -0,0 +1,76 @@ +export type PtyConnectionState = + | "connecting" + | "open" + | "reconnecting" + | "closed" + | "ended"; + +export const PTY_RECONNECT_INPUT_MESSAGE = + "Chat is reconnecting. Input will resume when connected."; + +// Minimum gap (ms) between page-resume-triggered reconnect attempts, so a +// burst of visibilitychange/pageshow/focus/online events on tab-return +// collapses into a single reconnect. +export const PTY_RESUME_RECONNECT_THROTTLE_MS = 1000; + +// If a socket sits in WS_CONNECTING past this budget it is treated as wedged +// (e.g. a half-open mobile socket after a radio handoff — the NS-591 case) +// and force-closed so `onclose` → scheduleReconnect can recover it. +export const PTY_CONNECTING_TIMEOUT_MS = 8000; + +export interface PtyResumeReconnectInput { + isActive: boolean; + visibilityState?: DocumentVisibilityState; + online: boolean; + socketReadyState?: number | null; + ptyState: PtyConnectionState; + connectInFlight?: boolean; +} + +const WS_CONNECTING = 0; +const WS_OPEN = 1; +const WS_CLOSING = 2; +const WS_CLOSED = 3; + +export function shouldReconnectPtyOnPageResume({ + isActive, + visibilityState, + online, + socketReadyState, + ptyState, + connectInFlight, +}: PtyResumeReconnectInput): boolean { + if (!isActive || !online || visibilityState === "hidden") { + return false; + } + if (ptyState === "ended") { + return false; + } + if (socketReadyState === WS_OPEN) { + return false; + } + // A connect is mid-flight (the async socket-open IIFE is awaiting its + // ticket URL and hasn't assigned wsRef yet, or the socket is still + // CONNECTING on a non-stuck attempt). Don't fire a redundant reconnect + // into that window unless the tab already believes it is reconnecting or + // closed and needs a fresh attempt. + if ( + (connectInFlight || socketReadyState === WS_CONNECTING) && + ptyState !== "reconnecting" && + ptyState !== "closed" + ) { + return false; + } + return ( + socketReadyState === null || + socketReadyState === undefined || + socketReadyState === WS_CLOSING || + socketReadyState === WS_CLOSED || + ptyState === "reconnecting" || + ptyState === "closed" + ); +} + +export function shouldBlockPtyInput(ptyState: PtyConnectionState): boolean { + return ptyState !== "open"; +} diff --git a/web/src/lib/reasoning-effort.test.ts b/web/src/lib/reasoning-effort.test.ts index 3ade0034724..9c2d0b139ae 100644 --- a/web/src/lib/reasoning-effort.test.ts +++ b/web/src/lib/reasoning-effort.test.ts @@ -14,7 +14,7 @@ describe("normalizeEffort", () => { }); it("passes through every valid effort level", () => { - for (const level of ["none", "minimal", "low", "medium", "high", "xhigh"]) { + for (const level of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]) { expect(normalizeEffort(level)).toBe(level); } }); @@ -26,7 +26,6 @@ describe("normalizeEffort", () => { it("falls back to medium for unknown values", () => { expect(normalizeEffort("turbo")).toBe("medium"); - expect(normalizeEffort("max")).toBe("medium"); // 'max' is a label, not a value expect(normalizeEffort(42)).toBe("medium"); }); }); @@ -41,7 +40,7 @@ describe("EFFORT_OPTIONS", () => { it("covers the real reasoning levels plus thinking-off", () => { // Invariant against hermes_constants.VALID_REASONING_EFFORTS + 'none'. const values = new Set(EFFORT_OPTIONS.map((o) => o.value)); - for (const level of ["none", "minimal", "low", "medium", "high", "xhigh"]) { + for (const level of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]) { expect(values.has(level)).toBe(true); } }); diff --git a/web/src/lib/reasoning-effort.ts b/web/src/lib/reasoning-effort.ts index 1e8313e0489..2d5fecd3120 100644 --- a/web/src/lib/reasoning-effort.ts +++ b/web/src/lib/reasoning-effort.ts @@ -20,7 +20,9 @@ export const EFFORT_OPTIONS: ReadonlyArray = [ { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, - { value: "xhigh", label: "Max" }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + { value: "ultra", label: "Ultra" }, ]; export const VALID_EFFORTS: ReadonlySet = new Set( diff --git a/web/src/lib/session-import.test.ts b/web/src/lib/session-import.test.ts new file mode 100644 index 00000000000..04f07debd98 --- /dev/null +++ b/web/src/lib/session-import.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { importSummary, parseImportSessions } from "./session-import"; + +describe("parseImportSessions", () => { + it("accepts a single exported session", () => { + expect(parseImportSessions('{"id":"session-1","messages":[]}')).toEqual([ + { id: "session-1", messages: [] }, + ]); + }); + + it("accepts arrays and wrapped session exports", () => { + const sessions = [{ id: "one" }, { id: "two" }]; + expect(parseImportSessions(JSON.stringify(sessions))).toEqual(sessions); + expect(parseImportSessions(JSON.stringify({ sessions }))).toEqual(sessions); + }); + + it("accepts JSONL session exports", () => { + expect(parseImportSessions('{"id":"one"}\n\n{"id":"two"}\n')).toEqual([ + { id: "one" }, + { id: "two" }, + ]); + }); + + it("rejects empty files and non-object entries", () => { + expect(() => parseImportSessions(" \n")).toThrow("File is empty"); + expect(() => parseImportSessions('[{"id":"one"},42]')).toThrow( + "Expected exported session JSON or JSONL", + ); + }); +}); + +describe("importSummary", () => { + it("includes skipped and detached counts only when present", () => { + expect( + importSummary({ + ok: true, + imported: 2, + skipped: 1, + detached: 1, + imported_ids: ["one", "two"], + skipped_ids: ["existing"], + errors: [], + }), + ).toBe("2 imported; 1 skipped; 1 detached from missing parents"); + }); +}); diff --git a/web/src/lib/session-import.ts b/web/src/lib/session-import.ts new file mode 100644 index 00000000000..49d09fd67c8 --- /dev/null +++ b/web/src/lib/session-import.ts @@ -0,0 +1,46 @@ +import type { SessionImportResponse } from "@/lib/api"; + +export type ImportableSession = Record; + +function normalizeImportSessions(value: unknown): ImportableSession[] { + const candidate = + value && + typeof value === "object" && + !Array.isArray(value) && + Array.isArray((value as { sessions?: unknown }).sessions) + ? (value as { sessions: unknown[] }).sessions + : Array.isArray(value) + ? value + : [value]; + + const sessions = candidate.filter( + (item): item is ImportableSession => + !!item && typeof item === "object" && !Array.isArray(item), + ); + if (sessions.length !== candidate.length) { + throw new Error("Expected exported session JSON or JSONL"); + } + return sessions; +} + +export function parseImportSessions(text: string): ImportableSession[] { + const trimmed = text.trim(); + if (!trimmed) throw new Error("File is empty"); + + try { + return normalizeImportSessions(JSON.parse(trimmed)); + } catch (jsonError) { + const lines = trimmed.split(/\r?\n/).filter((line) => line.trim()); + if (lines.length <= 1) throw jsonError; + return normalizeImportSessions(lines.map((line) => JSON.parse(line))); + } +} + +export function importSummary(result: SessionImportResponse): string { + const parts = [`${result.imported} imported`]; + if (result.skipped > 0) parts.push(`${result.skipped} skipped`); + if (result.detached > 0) { + parts.push(`${result.detached} detached from missing parents`); + } + return parts.join("; "); +} diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index 061468e15ca..625fd9bccf4 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -336,7 +336,11 @@ export default function ChannelsPage() { {editing && (
e.target === e.currentTarget && setEditing(null)} role="dialog" aria-modal="true" @@ -345,7 +349,7 @@ export default function ChannelsPage() {
+
+
+ )} + {/* NS-504: the agent process exited (e.g. `/exit` or a new session). Offer an in-place restart so the user never has to refresh the whole page to get a working chat back. */} - {sessionEnded && ( -
+ {ptyState === "ended" && ( +
Session ended.
)} + + {!isSearching && ( + + )}
{showPagination && ( diff --git a/website/docs/developer-guide/contributing.md b/website/docs/developer-guide/contributing.md index 52ec92d28ad..5f5d7fd0490 100644 --- a/website/docs/developer-guide/contributing.md +++ b/website/docs/developer-guide/contributing.md @@ -31,12 +31,12 @@ We value contributions in this order: ### Prerequisites -| Requirement | Notes | -|-------------|-------| -| **Git** | With the `git-lfs` extension installed | -| **Python 3.11–3.13** | uv will install it if missing | -| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) | -| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) | +| Requirement | Notes | +| -------------------- | --------------------------------------------------------------------------------------------- | +| **Git** | With the `git-lfs` extension installed | +| **Python 3.11–3.13** | uv will install it if missing | +| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) | +| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) | ### Install with the standard installer @@ -66,6 +66,14 @@ git checkout -b fix/description scripts/run_tests.sh ``` +You can also run a fully isolated Hermes instance (throwaway HERMES_HOME, separate Electron +userData, distinct Electron app name to avoid the single-instance lock): + +```bash +scripts/dev-sandbox.sh python -m hermes_cli.main +scripts/dev-sandbox.sh --persistent python -m hermes_cli.main desktop # state survives restarts, but lives in the worktree :) +``` + ### Manual clone fallback Use this only if you intentionally do not want Hermes' managed install layout @@ -143,7 +151,7 @@ See **[Platform Support](../getting-started/platform-support.md)**. Native Windo When contributing code, keep these rules in mind: -- **Don't add unguarded `signal.SIGKILL` references.** It's not defined on Windows. Either route through `gateway.status.terminate_pid(pid, force=True)` (the centralized primitive that does `taskkill /T /F` on Windows and SIGKILL on POSIX), or fall back with `getattr(signal, "SIGKILL", signal.SIGTERM)`. +- **Don't add unguarded `signal.SIGKILL` references.** It's not defined on Windows. Either route through `gateway.status.terminate_pid(pid, force=True)` (the centralized primitive that does `taskkill /T /F` on Windows and SIGKILL on POSIX), or fall back with `getattr(signal, "SIGKILL", signal.SIGTERM)`. - **Catch `OSError` alongside `ProcessLookupError` on `os.kill(pid, 0)` probes.** Windows raises `OSError` (WinError 87, "parameter is incorrect") for an already-gone PID instead of `ProcessLookupError`. - **Don't force the terminal to POSIX semantics.** `os.setsid`, `os.killpg`, `os.getpgid`, `os.fork` all raise on Windows — gate them with `if sys.platform != "win32":` or `if os.name != "nt":`. - **Open files with an explicit `encoding="utf-8"`.** The Python default on Windows is the system locale (often cp1252), which mojibakes or crashes on non-Latin text. @@ -198,15 +206,15 @@ Hermes has terminal access. Security matters. ### Existing Protections -| Layer | Implementation | -|-------|---------------| -| **Sudo password piping** | Uses `shlex.quote()` to prevent shell injection | -| **Dangerous command detection** | Regex patterns in `tools/approval.py` with user approval flow | -| **Cron prompt injection** | Scanner blocks instruction-override patterns | -| **Write deny list** | Protected paths resolved via `os.path.realpath()` to prevent symlink bypass | -| **Skills guard** | Security scanner for hub-installed skills | -| **Code execution sandbox** | Child process runs with API keys stripped | -| **Container hardening** | Docker: all capabilities dropped, no privilege escalation, PID limits | +| Layer | Implementation | +| ------------------------------- | --------------------------------------------------------------------------- | +| **Sudo password piping** | Uses `shlex.quote()` to prevent shell injection | +| **Dangerous command detection** | Regex patterns in `tools/approval.py` with user approval flow | +| **Cron prompt injection** | Scanner blocks instruction-override patterns | +| **Write deny list** | Protected paths resolved via `os.path.realpath()` to prevent symlink bypass | +| **Skills guard** | Security scanner for hub-installed skills | +| **Code execution sandbox** | Child process runs with API keys stripped | +| **Container hardening** | Docker: all capabilities dropped, no privilege escalation, PID limits | ### Contributing Security-Sensitive Code @@ -238,6 +246,7 @@ refactor/description # Code restructuring ### PR Description Include: + - **What** changed and **why** - **How to test** it - **What platforms** you tested on @@ -251,18 +260,19 @@ We use [Conventional Commits](https://www.conventionalcommits.org/): (): ``` -| Type | Use for | -|------|---------| -| `fix` | Bug fixes | -| `feat` | New features | -| `docs` | Documentation | -| `test` | Tests | -| `refactor` | Code restructuring | -| `chore` | Build, CI, dependency updates | +| Type | Use for | +| ---------- | ----------------------------- | +| `fix` | Bug fixes | +| `feat` | New features | +| `docs` | Documentation | +| `test` | Tests | +| `refactor` | Code restructuring | +| `chore` | Build, CI, dependency updates | Scopes: `cli`, `gateway`, `tools`, `skills`, `agent`, `install`, `whatsapp`, `security` Examples: + ``` fix(cli): prevent crash in save_config_value when model is a string feat(gateway): add WhatsApp multi-user session isolation diff --git a/website/docs/developer-guide/programmatic-integration.md b/website/docs/developer-guide/programmatic-integration.md index d21edbf85c3..16963bdd08f 100644 --- a/website/docs/developer-guide/programmatic-integration.md +++ b/website/docs/developer-guide/programmatic-integration.md @@ -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 diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 907af9c2402..2b390b3edef 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -113,6 +113,7 @@ Good defaults: | **OpenAI Codex** | ChatGPT OAuth, uses Codex models | Device code auth via `hermes model` | | **Anthropic** | Claude models directly — Max plan + extra usage credits (OAuth), or API key for pay-per-token | `hermes model` → OAuth login (requires Max + extra credits), or an Anthropic API key | | **OpenRouter** | Multi-provider routing across many models | Enter your API key | +| **Fireworks AI** | Direct OpenAI-compatible model API | Set `FIREWORKS_API_KEY` | | **Z.AI** | GLM / Zhipu-hosted models | Set `GLM_API_KEY` / `ZAI_API_KEY` (also accepts `Z_AI_API_KEY`) | | **Kimi / Moonshot** | Moonshot-hosted coding and chat models | Set `KIMI_API_KEY` (or the Kimi-Coding-specific `KIMI_CODING_API_KEY`) | | **Kimi / Moonshot China** | China-region Moonshot endpoint | Set `KIMI_CN_API_KEY` | diff --git a/website/docs/guides/work-with-skills.md b/website/docs/guides/work-with-skills.md index f191ff146d1..768a7c9cf83 100644 --- a/website/docs/guides/work-with-skills.md +++ b/website/docs/guides/work-with-skills.md @@ -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 ``` diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index a49b15d04de..d07d27e22b3 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -20,6 +20,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro | **GitHub Copilot ACP** | `hermes model` (spawns local `copilot --acp --stdio`) | | **Anthropic** | `hermes model` (Claude Max + extra usage credits via OAuth; also supports Anthropic API key or manual setup-token — see note below) | | **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` | +| **Fireworks AI** | `FIREWORKS_API_KEY` in `~/.hermes/.env` (provider: `fireworks`; aliases: `fireworks-ai`, `fw`) | | **NovitaAI** | `NOVITA_API_KEY` in `~/.hermes/.env` (provider: `novita`, 200+ models, Model API, Agent Sandbox, GPU Cloud) | | **z.ai / GLM** | `GLM_API_KEY` in `~/.hermes/.env` (provider: `zai`) | | **Kimi / Moonshot** | `KIMI_API_KEY` in `~/.hermes/.env` (provider: `kimi-coding`) | @@ -214,6 +215,10 @@ model: These providers have built-in support with dedicated provider IDs. Set the API key and use `--provider` to select: ```bash +# Fireworks AI +hermes chat --provider fireworks --model accounts/fireworks/models/kimi-k2p6 +# Requires: FIREWORKS_API_KEY in ~/.hermes/.env + # NovitaAI Model API hermes chat --provider novita --model moonshotai/kimi-k2.5 # Requires: NOVITA_API_KEY in ~/.hermes/.env @@ -260,6 +265,8 @@ hermes chat --provider gmi --model zai-org/GLM-5.1-FP8 # Requires: GMI_API_KEY in ~/.hermes/.env ``` +Fireworks uses its native slash-form catalog IDs, such as `accounts/fireworks/models/kimi-k2p6`. Run `hermes model`, choose **Fireworks AI**, and select from the live catalog or enter another Fireworks model ID. The default endpoint is `https://api.fireworks.ai/inference/v1`; configure a different endpoint through `model.base_url` in `config.yaml`, not `.env`. + Or set the provider permanently in `config.yaml`: ```yaml model: diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 6fa916786f3..804a25aab41 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -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 ` 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 ` instead. ## `hermes bundles` diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index b7df6823837..53f7a18a0be 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -14,6 +14,7 @@ Hermes reads environment variables from the process environment and, for user-ma |----------|-------------| | `OPENROUTER_API_KEY` | OpenRouter API key (recommended for flexibility) | | `OPENROUTER_BASE_URL` | Override the OpenRouter-compatible base URL | +| `FIREWORKS_API_KEY` | Fireworks AI API key ([app.fireworks.ai](https://app.fireworks.ai/settings/users/api-keys)). Configure endpoint overrides with `model.base_url` in `config.yaml`. | | `HERMES_OPENROUTER_CACHE` | Enable OpenRouter response caching (`1`/`true`/`yes`/`on`). Overrides `openrouter.response_cache` in config.yaml. See [Response Caching](https://openrouter.ai/docs/guides/features/response-caching). | | `HERMES_OPENROUTER_CACHE_TTL` | Cache TTL in seconds (1-86400). Overrides `openrouter.response_cache_ttl` in config.yaml. | | `NOUS_BASE_URL` | Override Nous Portal base URL (rarely needed; development/testing only) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 46afc11bcfd..e65399a67d8 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1170,7 +1170,7 @@ These options apply to **auxiliary task configs** (`auxiliary:`, `compression:`) | `"xai-oauth"` | Force xAI Grok OAuth (browser login for SuperGrok or X Premium+ subscribers, no API key). Same OAuth token covers chat, TTS, image, video, and transcription. | `hermes model` → xAI Grok OAuth (SuperGrok / Premium+) | | `"main"` | Use your active custom/main endpoint. This can come from `OPENAI_BASE_URL` + `OPENAI_API_KEY` or from a custom endpoint saved via `hermes model` / `config.yaml`. Works with OpenAI, local models, or any OpenAI-compatible API. **Auxiliary tasks only — not valid for `model.provider`.** | Custom endpoint credentials + base URL | -Direct API-key providers from the main provider catalog also work here when you want side tasks to bypass your default router. `gmi` is valid once `GMI_API_KEY` is configured: +Direct API-key providers from the main provider catalog also work here when you want side tasks to bypass your default router. For example, `gmi` is valid once `GMI_API_KEY` is configured, and `fireworks` is valid once `FIREWORKS_API_KEY` is configured: ```yaml auxiliary: @@ -1179,7 +1179,7 @@ auxiliary: model: "anthropic/claude-opus-4.6" ``` -For GMI auxiliary routing, use the exact model ID returned by GMI's `/v1/models` endpoint. +For GMI auxiliary routing, use the exact model ID returned by GMI's `/v1/models` endpoint. Fireworks model IDs use the provider's native slash form, for example `accounts/fireworks/models/glm-5p2`. ### Common Setups @@ -1276,7 +1276,7 @@ Control how much "thinking" the model does before responding: ```yaml agent: - reasoning_effort: "" # empty = medium (default). Options: none, minimal, low, medium, high, xhigh (max) + reasoning_effort: "" # empty = medium. Options: none, minimal, low, medium, high, xhigh, max, ultra ``` When unset (default), reasoning effort defaults to "medium" — a balanced level that works well for most tasks. Setting a value overrides it — higher reasoning effort gives better results on complex tasks at the cost of more tokens and latency. @@ -1285,10 +1285,9 @@ When unset (default), reasoning effort defaults to "medium" — a balanced level These models use *adaptive* thinking and don't accept the usual `reasoning.effort` field — OpenRouter ignores it for them. Hermes transparently routes your `reasoning_effort` to OpenRouter's `verbosity` parameter instead (which maps to -Anthropic's `output_config.effort`), so the same `low`/`medium`/`high`/`xhigh` -knob keeps working — no extra configuration needed. `none` (or unset) leaves the -model on its own adaptive default. (`max` is accepted on the wire but is not a -selectable `reasoning_effort` value; `xhigh` is the configurable ceiling.) The +Anthropic's `output_config.effort`), so the same effort knob keeps working with +the levels supported by the selected model. `none` (or unset) leaves the model +on its own adaptive default. The native Anthropic provider already controls effort directly and is unaffected. ::: @@ -1879,13 +1878,13 @@ Control how Hermes handles potentially dangerous commands: ```yaml approvals: - mode: manual # manual | smart | off + mode: smart # smart | manual | off ``` | Mode | Behavior | |------|----------| -| `manual` (default) | Prompt the user before executing any flagged command. In the CLI, shows an interactive approval dialog. In messaging, queues a pending approval request. | -| `smart` | Use an auxiliary LLM to assess whether a flagged command is actually dangerous. Low-risk commands are auto-approved with session-level persistence. Genuinely risky commands are escalated to the user. | +| `smart` (default) | Use an auxiliary LLM to assess whether a flagged command is actually dangerous. Low-risk commands are auto-approved for that command only. Genuinely risky commands are denied; uncertain decisions escalate to the user. | +| `manual` | Prompt the user before executing any flagged command. In the CLI, shows an interactive approval dialog. In messaging, queues a pending approval request. | | `off` | Skip all approval checks. Equivalent to `HERMES_YOLO_MODE=true`. **Use with caution.** | Smart mode is particularly useful for reducing approval fatigue — it lets the agent work more autonomously on safe operations while still catching genuinely destructive commands. diff --git a/website/docs/user-guide/features/api-server.md b/website/docs/user-guide/features/api-server.md index 4f1db5ab0c2..cbcb1f954d5 100644 --- a/website/docs/user-guide/features/api-server.md +++ b/website/docs/user-guide/features/api-server.md @@ -227,7 +227,15 @@ Health check. Returns `{"status": "ok"}`. Also available at **GET /v1/health** f ### GET /health/detailed -Extended health check that also reports active sessions, running agents, and resource usage. Useful for monitoring/observability tooling. +Authenticated readiness check for monitoring and control planes. It reports +bounded status for the active profile's config, state database, configured +model, disk space, gateway/platform state, active API runs, pending process +completions, and active delegations. The response exposes status and counts, +not config values, credentials, paths, commands, queue payloads, or raw errors. + +The public `/health` route remains a cheap liveness probe and does not run +readiness checks. A degraded readiness result still uses HTTP 200; inspect the +top-level `status` and `readiness.checks` fields. ## Runs API (streaming-friendly alternative) @@ -268,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 diff --git a/website/docs/user-guide/features/batch-processing.md b/website/docs/user-guide/features/batch-processing.md index 1abbac977bd..87bbf03af16 100644 --- a/website/docs/user-guide/features/batch-processing.md +++ b/website/docs/user-guide/features/batch-processing.md @@ -83,7 +83,7 @@ Entries can optionally include: | Parameter | Description | |-----------|-------------| -| `--reasoning_effort` | Effort level: `none`, `minimal`, `low`, `medium`, `high`, `xhigh` | +| `--reasoning_effort` | Reasoning effort: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra` | | `--reasoning_disabled` | Completely disable reasoning/thinking tokens | ### Advanced Options diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index 037c2e806ae..df5a156f85c 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -129,6 +129,21 @@ When you provide a `tasks` array, subagents run in **parallel** using a thread p Single-task delegation runs directly without thread pool overhead. +### Durable background completions + +When a background delegation finishes, Hermes stores its completion event in +the active profile's `state.db` before publishing it to the normal fresh-turn +queue. If Hermes restarts after completion but before delivery, the pending +event is restored and routed through the same ownership checks. Competing +consumers use a durable claim, so only the consumer that successfully accepts +the synthetic turn acknowledges delivery; failed attempts release the claim for +retry. + +This does not resume child execution after a crash. A delegation whose owner +process disappears while it is still running is recorded as `unknown`, because +Hermes cannot prove whether its external side effects happened. Pending and +delivered records are bounded and profile-local. + ## Model Override You can configure a different model for subagents via `config.yaml` — useful for delegating simple tasks to cheaper/faster models: @@ -225,14 +240,16 @@ delegate_task( ## Lifetime and Durability -:::warning delegate_task is synchronous — not durable -`delegate_task` runs **inside the parent's current turn**. It blocks the parent until every child finishes (or is cancelled). It is **not** a background job queue: +:::warning Background completion durability is not durable execution +By default, `delegate_task` runs **inside the parent's current turn** and blocks until every child finishes. With `background=true`, the child may continue after that turn returns while the owning session and Hermes process remain alive: - If the parent is interrupted (user sends a new message, `/stop`, `/new`), all active children are cancelled and return `status="interrupted"`. Their in-progress work is discarded. -- Children do **not** continue running after the parent turn ends. +- Explicit session close/reset interrupts that session's background children. Closing a TUI viewer of a gateway-owned session does not kill the gateway's work. +- A Hermes process restart does **not** resume a running child. Its attempt becomes `unknown` because Hermes cannot prove which side effects happened. +- A child that completed before restart but whose result was not delivered is restored and routed back through the owning session's normal checks. - Cancelled children return a structured result (`status="interrupted"`, `exit_reason="interrupted"`), but because the parent was interrupted too, that result often never makes it into a user-visible reply. -For **durable long-running work** that must survive interrupts or outlive the current turn, use: +For **durable execution** that must survive session closure or process restart, use: - `cronjob` (action=`create`) — schedules a separate agent run; immune to parent-turn interrupts. - `terminal(background=True, notify_on_complete=True)` — long-running shell commands that keep running while the agent does other things. diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index 224d20198b3..f38ed9343b9 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -390,8 +390,8 @@ def register(ctx): | [`subagent_start`](#subagent_start) | A `delegate_task` child has been constructed and is about to run | ignored | | [`subagent_stop`](#subagent_stop) | A `delegate_task` child has exited | ignored | | [`pre_gateway_dispatch`](#pre_gateway_dispatch) | Gateway received a user message, before auth + dispatch | `{"action": "skip" \| "rewrite" \| "allow", ...}` to influence flow | -| [`pre_approval_request`](#pre_approval_request) | Dangerous command needs user approval, before the prompt/notification is sent | ignored | -| [`post_approval_response`](#post_approval_response) | User responded to an approval prompt (or it timed out) | ignored | +| [`pre_approval_request`](#pre_approval_request) | An approval decision is requested, including smart-mode auto decisions | ignored | +| [`post_approval_response`](#post_approval_response) | An approval decision is made (or a prompt times out) | ignored | | [`transform_tool_result`](#transform_tool_result) | After any tool returns, before the result is handed back to the model | `str` to replace the result, `None` to leave unchanged | | [`transform_terminal_output`](#transform_terminal_output) | Inside the `terminal` tool, before truncation/ANSI-strip/redact | `str` to replace the raw output, `None` to leave unchanged | | [`transform_llm_output`](#transform_llm_output) | After the tool-calling loop completes, before the final response is delivered | `str` to replace the response text, `None`/empty to leave unchanged | @@ -1060,7 +1060,7 @@ def register(ctx): ### `pre_approval_request` -Fires **immediately before** an approval request is shown to the user — covers every surface: interactive CLI, the Ink TUI, gateway platforms (Telegram, Discord, Slack, WhatsApp, Matrix, etc.), and ACP clients (VS Code, Zed, JetBrains). +Fires before an approval decision is requested. It covers prompted surfaces—interactive CLI, Ink TUI, gateway platforms, and ACP clients—and `approvals.mode=smart` decisions made without a human prompt (`surface="smart"`). In smart mode, the hook runs before the auxiliary LLM is called. This is the right place to wire a custom notifier — for example, a macOS menu-bar app that pops an allow/deny notification, or an audit log that records every approval request with context. @@ -1080,12 +1080,12 @@ def my_callback( | Parameter | Type | Description | |-----------|------|-------------| -| `command` | `str` | The shell command awaiting approval | +| `command` | `str` | Terminal command or `execute_code` script being assessed. Smart and gateway payloads are redacted before observer dispatch. Smart observer redaction is mandatory even when `security.redact_secrets` is disabled; if redaction fails, smart hooks are skipped. | | `description` | `str` | Human-readable reason(s) the command is flagged (combined when multiple patterns match) | | `pattern_key` | `str` | Primary pattern key that triggered the approval (e.g. `"rm_rf"`, `"sudo"`) | | `pattern_keys` | `list[str]` | All pattern keys that matched | | `session_key` | `str` | Session identifier, useful for scoping notifications per-chat | -| `surface` | `str` | `"cli"` for interactive CLI/TUI prompts, `"gateway"` for async platform approvals | +| `surface` | `str` | `"cli"` for interactive CLI/TUI prompts, `"gateway"` for async platform approvals, or `"smart"` for auxiliary-LLM auto approve/deny decisions | **Return value:** ignored. Hooks here are observer-only; they cannot veto or pre-answer the approval. Use [`pre_tool_call`](#pre_tool_call) to block a tool before it reaches the approval system. @@ -1112,7 +1112,7 @@ def register(ctx): ### `post_approval_response` -Fires **after** the user responds to an approval prompt (or the prompt times out). +Fires after a prompted or smart approval decision (or after a prompt times out). **Callback signature:** @@ -1133,7 +1133,8 @@ Same kwargs as `pre_approval_request`, plus: | Parameter | Type | Description | |-----------|------|-------------| -| `choice` | `str` | One of `"once"`, `"session"`, `"always"`, `"deny"`, or `"timeout"` | +| `choice` | `str` | Prompted surfaces use `"once"`, `"session"`, `"always"`, `"deny"`, or `"timeout"`; smart decisions use `"smart_approve"` or `"smart_deny"` | +| `decided_by` | `str` | `"aux_llm"` for smart decisions; absent on prompted surfaces | **Return value:** ignored. diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index bd8bd27e3bf..b505ccc9bf4 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -63,7 +63,7 @@ They coexist: a kanban worker may call `delegate_task` internally during its run - **Link** — `task_links` row recording a parent → child dependency. The dispatcher promotes `todo → ready` when all parents are `done`. - **Comment** — the inter-agent protocol. Agents and humans append comments; when a worker is (re-)spawned it reads the full comment thread as part of its context. - **Workspace** — the directory a worker operates in. Three kinds: - - `scratch` (default) — fresh tmp dir under `~/.hermes/kanban/workspaces//` (or `~/.hermes/kanban/boards//workspaces//` on non-default boards). **Deleted when the task completes** — scratch is ephemeral by design, so the dir is wiped the moment the worker (or `hermes kanban complete `) marks the task done. If you want to keep the worker's output, use `worktree:` or `dir:` instead. The first time a scratch workspace is created on an install, the dispatcher logs a warning and emits a `tip_scratch_workspace` event on the task (visible via `hermes kanban show `). + - `scratch` (default) — fresh tmp dir under `~/.hermes/kanban/workspaces//` (or `~/.hermes/kanban/boards//workspaces//` on non-default boards). **Deleted when the task completes** — scratch is ephemeral by design. Files explicitly declared through `kanban_complete(artifacts=[...])` are copied into durable per-task attachment storage before cleanup; existing deliverable paths in legacy completion summaries receive the same treatment. Other scratch files are removed. A missing declared scratch artifact keeps the task in-flight so the worker can correct the path and retry. Use `worktree:` or `dir:` when the whole workspace should remain available. The first time a scratch workspace is created on an install, the dispatcher logs a warning and emits a `tip_scratch_workspace` event on the task (visible via `hermes kanban show `). - `dir:` — an existing shared directory (Obsidian vault, mail ops dir, per-account folder). **Must be an absolute path.** Relative paths like `dir:../tenants/foo/` are rejected at dispatch because they'd resolve against whatever CWD the dispatcher happens to be in, which is ambiguous and a confused-deputy escape vector. The path is otherwise trusted — it's your box, your filesystem, the worker runs with your uid. This is the trusted-local-user threat model; kanban is single-host by design. **Preserved on completion.** - `worktree` — a git worktree under `.worktrees//` for coding tasks. Use `worktree:` to pin the exact target path. Worker-side `git worktree add` creates it, using `--branch` when provided. **Preserved on completion.** - **Dispatcher** — a long-lived loop that, every N seconds (default 60): reclaims stale claims, reclaims crashed workers (PID gone but TTL not yet expired), promotes ready tasks, atomically claims, spawns assigned profiles. Runs **inside the gateway** by default (`kanban.dispatch_in_gateway: true`). One dispatcher sweeps all boards per tick; workers are spawned with `HERMES_KANBAN_BOARD` pinned so they can't see other boards. After `kanban.failure_limit` consecutive spawn failures on the same task (default: 2) the dispatcher auto-blocks it with the last error as the reason — prevents thrashing on tasks whose profile doesn't exist, workspace can't mount, etc. diff --git a/website/docs/user-guide/features/provider-routing.md b/website/docs/user-guide/features/provider-routing.md index 3dd6e69787e..ff8a9ef56cc 100644 --- a/website/docs/user-guide/features/provider-routing.md +++ b/website/docs/user-guide/features/provider-routing.md @@ -1,18 +1,18 @@ --- title: Provider Routing -description: Configure OpenRouter provider preferences to optimize for cost, speed, or quality. +description: Configure OpenRouter or Nous Portal provider preferences to optimize for cost, speed, or quality. sidebar_label: Provider Routing sidebar_position: 7 --- # Provider Routing -When using [OpenRouter](https://openrouter.ai) as your LLM provider, Hermes Agent supports **provider routing** — fine-grained control over which underlying AI providers handle your requests and how they're prioritized. +When using [OpenRouter](https://openrouter.ai) or [Nous Portal](/integrations/nous-portal) as your LLM provider, Hermes Agent supports **provider routing** — fine-grained control over which underlying AI providers handle your requests and how they're prioritized. OpenRouter routes requests to many providers (e.g., Anthropic, Google, AWS Bedrock, Together AI). Provider routing lets you optimize for cost, speed, quality, or enforce specific provider requirements. :::tip -Traffic routed through [Nous Portal](/integrations/nous-portal) still respects per-model routing and priority configs — and Portal subscribers get 10% off token-billed providers. +Traffic routed through Nous Portal respects the same provider preferences — and Portal subscribers get 10% off token-billed providers. ::: ## Configuration @@ -30,7 +30,7 @@ provider_routing: ``` :::info -Provider routing only applies when using OpenRouter. It has no effect with direct provider connections (e.g., connecting directly to the Anthropic API). +Provider routing only applies when using OpenRouter or Nous Portal. It has no effect with direct provider connections (e.g., connecting directly to the Anthropic API). ::: ## Options @@ -52,13 +52,13 @@ provider_routing: ### `only` -Whitelist of provider names. When set, **only** these providers will be used. All others are excluded. +Whitelist of provider slugs. When set, **only** these providers will be used. All others are excluded. Use the lowercase slug shown by OpenRouter for each provider. ```yaml provider_routing: only: - - "Anthropic" - - "Google" + - "anthropic" + - "google" ``` ### `ignore` @@ -68,8 +68,8 @@ Blacklist of provider names. These providers will **never** be used, even if the ```yaml provider_routing: ignore: - - "Together" - - "DeepInfra" + - "together" + - "deepinfra" ``` ### `order` @@ -79,9 +79,9 @@ Explicit priority order. Providers listed first are preferred. Unlisted provider ```yaml provider_routing: order: - - "Anthropic" - - "Google" - - "AWS Bedrock" + - "anthropic" + - "google" + - "amazon-bedrock" ``` ### `require_parameters` @@ -138,7 +138,7 @@ Ensure all requests go through a specific provider for consistency: ```yaml provider_routing: only: - - "Anthropic" + - "anthropic" ``` ### Avoid Specific Providers @@ -148,8 +148,8 @@ Exclude providers you don't want to use (e.g., for data privacy): ```yaml provider_routing: ignore: - - "Together" - - "Lepton" + - "together" + - "lepton" data_collection: "deny" ``` @@ -160,14 +160,14 @@ Try your preferred providers first, fall back to others if unavailable: ```yaml provider_routing: order: - - "Anthropic" - - "Google" + - "anthropic" + - "google" require_parameters: true ``` ## How It Works -Provider routing preferences are passed to the OpenRouter API via the `extra_body.provider` field on every API call. This applies to both: +Provider routing preferences are passed to OpenRouter or Nous Portal on agent chat requests and iteration-limit summaries via the `extra_body.provider` field. (`extra_body` is the OpenAI Python SDK argument; it becomes the top-level `provider` object in the JSON request.) Auxiliary tasks such as compression and title generation are configured independently under `auxiliary..extra_body`. - **CLI mode** — configured in `~/.hermes/config.yaml`, loaded at startup - **Gateway mode** — same config file, loaded when the gateway starts @@ -189,7 +189,7 @@ You can combine multiple options. For example, sort by price but exclude certain ```yaml provider_routing: sort: "price" - ignore: ["Together"] + ignore: ["together"] require_parameters: true data_collection: "deny" ``` @@ -197,8 +197,8 @@ provider_routing: ## Default Behavior -When no `provider_routing` section is configured (the default), OpenRouter uses its own default routing logic, which generally balances cost and availability automatically. +When no `provider_routing` section is configured (the default), the aggregator uses its own default routing logic, which generally balances cost and availability automatically. :::tip Provider Routing vs. Fallback Models -Provider routing controls which **sub-providers within OpenRouter** handle your requests. For automatic failover to an entirely different provider when your primary model fails, see [Fallback Providers](/user-guide/features/fallback-providers). +Provider routing controls which **sub-providers behind OpenRouter or Nous Portal** handle your requests. For automatic failover to an entirely different provider when your primary model fails, see [Fallback Providers](/user-guide/features/fallback-providers). ::: diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 19fffb1f1b2..fccff01e1a2 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -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 --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//` 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 diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index bb2a8197b64..2c8ed3a2ee6 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.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. diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index 60eec972e28..71d1b131d2e 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -30,7 +30,7 @@ The approval system supports three modes, configured via `approvals.mode` in `~/ ```yaml approvals: - mode: manual # manual | smart | off + mode: smart # smart | manual | off timeout: 60 # seconds to wait for user response (default: 60) cron_mode: deny # deny | approve — what cron jobs do when they hit a dangerous command mcp_reload_confirm: true # /reload-mcp asks before invalidating the MCP tool cache @@ -41,7 +41,7 @@ The full set of keys: | Key | Default | What it controls | |---|---|---| -| `mode` | `manual` | Approval policy for dangerous shell commands — see the table below. | +| `mode` | `smart` | Approval policy for dangerous shell commands — see the table below. | | `timeout` | `60` | Seconds Hermes waits for an approval reply before timing out. | | `cron_mode` | `deny` | How [cron jobs](./features/cron.md) behave headlessly when they trigger a dangerous-command prompt. `deny` blocks the command (the agent must find another path); `approve` auto-approves everything in cron context. | | `mcp_reload_confirm` | `true` | When true, `/reload-mcp` asks before rebuilding the MCP tool set. Rebuilding invalidates the provider prompt cache (tool schemas live in the system prompt), so the next message re-sends full input tokens. Users who click **Always Approve** flip this key to `false`. | @@ -49,8 +49,8 @@ The full set of keys: | Mode | Behavior | |------|----------| -| **manual** (default) | Always prompt the user for approval on dangerous commands | -| **smart** | Use an auxiliary LLM to assess risk. Low-risk commands (e.g., `python -c "print('hello')"`) are auto-approved. Genuinely dangerous commands are auto-denied. Uncertain cases escalate to a manual prompt. | +| **smart** (default) | Use an auxiliary LLM to assess risk. Low-risk commands (e.g., `python -c "print('hello')"`) are auto-approved for that command only. Genuinely dangerous commands are auto-denied. Uncertain cases escalate to a manual prompt. | +| **manual** | Always prompt the user for approval on dangerous commands. | | **off** | Disable all approval checks — equivalent to running with `--yolo`. All commands execute without prompts. | :::warning diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index caa66b64e7a..2dde2ad9d12 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -286,7 +286,7 @@ The registry of record is `hermes_cli/commands.py` — every consumer /config Show config (CLI) /model [name] Show or change model /personality [name] Set personality -/reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|show|hide) +/reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|max|ultra|show|hide) /verbose Cycle: off → new → all → verbose /voice [on|off|tts] Voice mode /yolo Toggle approval bypass @@ -492,10 +492,10 @@ hermes config set privacy.redact_pii false # disable (default) ### Command approval prompts -By default (`approvals.mode: manual`), Hermes prompts the user before running shell commands flagged as destructive (`rm -rf`, `git reset --hard`, etc.). The modes are: +By default (`approvals.mode: smart`), Hermes asks an auxiliary LLM to assess shell commands flagged as destructive (`rm -rf`, `git reset --hard`, etc.). The modes are: -- `manual` — always prompt (default) -- `smart` — use an auxiliary LLM to auto-approve low-risk commands, prompt on high-risk +- `smart` — auto-approve a low-risk command once, deny high-risk commands, and prompt when uncertain (default) +- `manual` — always prompt - `off` — skip all approval prompts (equivalent to `--yolo`) ```bash diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md index a443fab9915..d834538b254 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md @@ -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 ``` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md index a1191cfbd5f..d4ef1885a6e 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md @@ -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 `。 +- 传入 `http(s)://…/*.md` URL 可安装 `SKILL.md`,以及其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时,交互式终端会提示输入名称;非交互式界面(TUI 内的 `/skills install`、gateway 平台)需要改用 `--name `。 ## `hermes bundles` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md index c3ea44ef957..2d290c84652 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md @@ -1044,7 +1044,7 @@ auxiliary: ```yaml agent: - reasoning_effort: "" # 空 = 中等(默认)。选项:none、minimal、low、medium、high、xhigh(最大) + reasoning_effort: "" # 空 = 中等。选项:none、minimal、low、medium、high、xhigh、max、ultra ``` 未设置时(默认),推理努力程度默认为"medium" —— 适合大多数任务的平衡级别。设置值会覆盖它 —— 更高的推理努力程度在复杂任务上提供更好的结果,但代价是更多 token 和延迟。 @@ -1583,13 +1583,13 @@ security: ```yaml approvals: - mode: manual # manual | smart | off + mode: smart # smart | manual | off ``` | 模式 | 行为 | |------|----------| -| `manual`(默认) | 在执行任何被标记的命令之前提示用户。在 CLI 中显示交互式审批对话框。在消息中排队待处理的审批请求。 | -| `smart` | 使用辅助 LLM 评估被标记的命令是否真正危险。低风险命令以会话级持久性自动批准。真正有风险的命令升级给用户。 | +| `smart`(默认) | 使用辅助 LLM 评估被标记的命令是否真正危险。低风险命令仅对当前命令自动批准,真正危险的命令自动拒绝,不确定的情况升级给用户。 | +| `manual` | 在执行任何被标记的命令之前提示用户。在 CLI 中显示交互式审批对话框。在消息中排队待处理的审批请求。 | | `off` | 跳过所有审批检查。等同于 `HERMES_YOLO_MODE=true`。**谨慎使用。** | 智能模式对于减少审批疲劳特别有用 —— 它让 agent 在安全操作上更自主地工作,同时仍然捕获真正破坏性的命令。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/api-server.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/api-server.md index ec6cf483c51..d2e3ef14814 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/api-server.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/api-server.md @@ -227,7 +227,9 @@ OpenAI Responses API 格式。通过 `previous_response_id` 支持服务端对 ### GET /health/detailed -扩展健康检查,同时报告活跃 session、运行中的 agent 和资源使用情况。适用于监控/可观测性工具。 +面向监控和控制平面的已认证就绪检查。它会报告当前 profile 的配置、状态数据库、已配置模型、磁盘空间、gateway/platform 状态、活跃 API run、待处理进程完成通知和活跃 delegation 的有限状态。响应只暴露状态与计数,不包含配置值、凭据、路径、命令、队列载荷或原始错误。 + +公开的 `/health` 路由仍是低开销的存活探针,不运行就绪检查。就绪状态降级时仍返回 HTTP 200;请检查顶层 `status` 和 `readiness.checks` 字段。 ## Runs API(流式友好的替代方案) @@ -268,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(后台计划任务) diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/batch-processing.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/batch-processing.md index 0ecc8112b67..0c62b94ba40 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/batch-processing.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/batch-processing.md @@ -83,7 +83,7 @@ python batch_runner.py --list_distributions | 参数 | 说明 | |-----------|-------------| -| `--reasoning_effort` | 推理力度:`none`、`minimal`、`low`、`medium`、`high`、`xhigh` | +| `--reasoning_effort` | 推理力度:`none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`、`ultra` | | `--reasoning_disabled` | 完全禁用推理/思考 token | ### 高级选项 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/delegation.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/delegation.md index 6458a9ec71a..e5d2e5d7f7e 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/delegation.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/delegation.md @@ -127,7 +127,13 @@ delegate_task( - **结果排序:** 结果按任务索引排序,与输入顺序一致,不受完成顺序影响 - **中断传播:** 中断父智能体(例如发送新消息)会中断所有活跃的子智能体 -单任务委派直接运行,无线程池开销。 +单任务委派直接运行,不会产生线程池开销。 + +### 持久化后台完成事件 + +后台委派完成后,Hermes 会先把完成事件写入当前 profile 的 `state.db`,再发布到正常的新轮次队列。如果 Hermes 在完成后、交付前重启,待处理事件会被恢复,并继续经过相同的所有权检查。多个消费者通过持久化 claim 竞争;只有成功接收合成轮次的消费者会确认交付,失败尝试会释放 claim 以便重试。 + +这不会在崩溃后恢复子智能体执行。如果委派仍在运行时其所有者进程消失,Hermes 会将其记录为 `unknown`,因为无法证明外部副作用是否已经发生。待处理和已交付记录都有界,并按 profile 隔离。 ## 模型覆盖 @@ -226,14 +232,16 @@ delegate_task( ## 生命周期与持久性 -:::warning delegate_task 是同步的——不具备持久性 -`delegate_task` 在**父智能体的当前轮次内**运行。它会阻塞父智能体,直到所有子智能体完成(或被取消)。它**不是**后台任务队列: +:::warning 后台完成事件持久化并不等于执行持久化 +默认情况下,`delegate_task` 在**父智能体的当前轮次内**运行,并阻塞到所有子智能体完成。使用 `background=true` 时,只要所属会话和 Hermes 进程仍然存活,子智能体可以在该轮次返回后继续运行: - 如果父智能体被中断(用户发送新消息、`/stop`、`/new`),所有活跃的子智能体都会被取消并返回 `status="interrupted"`。其进行中的工作将被丢弃。 -- 子智能体在父智能体轮次结束后**不会**继续运行。 +- 显式关闭或重置会话会中断该会话的后台子智能体。关闭由 TUI 查看、但由网关拥有的会话不会终止网关自己的后台工作。 +- Hermes 进程重启后不会恢复仍在运行的子智能体;该尝试会变为 `unknown`,因为 Hermes 无法证明哪些外部副作用已经发生。 +- 如果子智能体在重启前已经完成、但结果尚未交付,该完成事件会被恢复,并重新经过所属会话的正常路由检查。 - 被取消的子智能体会返回结构化结果(`status="interrupted"`,`exit_reason="interrupted"`),但由于父智能体也被中断,该结果通常不会出现在用户可见的回复中。 -对于必须在中断后存活或超出当前轮次的**持久长时间运行工作**,请使用: +对于必须在会话关闭或进程重启后继续的**持久执行**,请使用: - `cronjob`(action=`create`)——调度独立的智能体运行;不受父智能体轮次中断影响。 - `terminal(background=True, notify_on_complete=True)`——长时间运行的 shell 命令,在智能体执行其他操作时持续运行。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md index 075296d687b..ee96d46f028 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md @@ -59,7 +59,7 @@ Hermes Kanban 是一个持久化任务看板,在所有 Hermes 配置文件之 - **Link(链接)** —— `task_links` 行,记录父 → 子依赖关系。当所有父任务变为 `done` 时,调度器将 `todo → ready`。 - **Comment(评论)** —— agent 间协议。Agent 和人类追加评论;当 worker 被(重新)启动时,它将完整的评论线程作为上下文的一部分读取。 - **Workspace(工作区)** —— worker 操作的目录。三种类型: - - `scratch`(默认)—— 在 `~/.hermes/kanban/workspaces//` 下(非默认看板为 `~/.hermes/kanban/boards//workspaces//`)创建的临时目录。**任务完成时删除** —— scratch 是临时性的,worker(或 `hermes kanban complete `)将任务标记为完成的那一刻,目录即被清除。如果想保留 worker 的输出,请使用 `worktree:` 或 `dir:`。在某次安装中首次创建 scratch 工作区时,调度器会记录警告并在任务上发出 `tip_scratch_workspace` 事件(可通过 `hermes kanban show ` 查看)。 + - `scratch`(默认)—— 在 `~/.hermes/kanban/workspaces//` 下(非默认看板为 `~/.hermes/kanban/boards//workspaces//`)创建的临时目录。**任务完成时删除** —— scratch 按设计是临时性的。通过 `kanban_complete(artifacts=[...])` 明确声明的文件会在清理前复制到持久的任务附件存储;旧版完成摘要中已存在的交付文件路径也会得到同样处理。其他 scratch 文件仍会被删除。如果声明的 scratch 交付文件不存在,任务会保持进行中,worker 可修正路径后重试。需要保留整个工作区时,请使用 `worktree:` 或 `dir:`。在某次安装中首次创建 scratch 工作区时,调度器会记录警告并在任务上发出 `tip_scratch_workspace` 事件(可通过 `hermes kanban show ` 查看)。 - `dir:` —— 现有的共享目录(Obsidian vault、邮件运维目录、每账号文件夹)。**必须是绝对路径。** 像 `dir:../tenants/foo/` 这样的相对路径在调度时会被拒绝,因为它们会相对于调度器碰巧所在的 CWD 解析,这是模糊的,也是混淆代理(confused-deputy)逃逸向量。路径本身是受信任的 —— 这是你的机器、你的文件系统,worker 以你的 uid 运行。这是受信任本地用户的威胁模型;kanban 设计为单主机。**完成时保留。** - `worktree` —— 用于编码任务的 git worktree,位于 `.worktrees//` 下。使用 `worktree:` 固定确切的目标路径。Worker 端的 `git worktree add` 创建它,提供 `--branch` 时使用该分支。**完成时保留。** - **Dispatcher(调度器)** —— 一个长期运行的循环,每 N 秒(默认 60 秒)执行一次:回收过期的认领、回收崩溃的 worker(PID 消失但 TTL 尚未过期)、推进就绪任务、原子性认领、启动已分配的配置文件。默认**在 gateway 内部运行**(`kanban.dispatch_in_gateway: true`)。每次 tick 一个调度器扫描所有看板;worker 启动时固定了 `HERMES_KANBAN_BOARD`,因此无法看到其他看板。在同一任务上连续启动失败 `kanban.failure_limit` 次(默认:2)后,调度器会以最后一个错误为原因自动阻塞该任务 —— 防止因配置文件不存在、工作区无法挂载等原因导致的反复抖动。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md index 5e71afd86fb..ea2da962557 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md @@ -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 --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 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/web-dashboard.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/web-dashboard.md index 7411c7d0ef5..6a711739246 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/web-dashboard.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/web-dashboard.md @@ -95,7 +95,7 @@ Chat 标签页是每次 `hermes dashboard` 启动的一部分——内嵌的浏 - **agent** — 最大迭代次数、gateway 超时、服务层级 - **delegation** — 子 agent 限制、推理力度 - **memory** — 提供商选择、上下文注入设置 -- **approvals** — 危险命令审批模式(ask/yolo/deny) +- **approvals** — 危险命令审批模式(smart/manual/off) - 更多——config.yaml 的每个部分都有对应的表单字段 具有已知有效值的字段(terminal 后端、皮肤、审批模式等)渲染为下拉菜单。布尔值渲染为开关。其余均为文本输入框。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md index 911b8624016..bde9a38431a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md @@ -30,14 +30,14 @@ Hermes Agent 采用纵深防御安全模型。本页涵盖所有安全边界— ```yaml approvals: - mode: manual # manual | smart | off + mode: smart # smart | manual | off timeout: 60 # 等待用户响应的秒数(默认:60) ``` | 模式 | 行为 | |------|----------| -| **manual**(默认) | 始终提示用户审批危险命令 | -| **smart** | 使用辅助 LLM 评估风险。低风险命令(如 `python -c "print('hello')"` )自动批准,真正危险的命令自动拒绝,不确定的情况升级为手动提示。 | +| **smart**(默认) | 使用辅助 LLM 评估风险。低风险命令(如 `python -c "print('hello')"`)仅对当前命令自动批准,真正危险的命令自动拒绝,不确定的情况升级为手动提示。 | +| **manual** | 始终提示用户审批危险命令。 | | **off** | 禁用所有审批检查——等同于使用 `--yolo` 运行。所有命令无需提示即可执行。 | :::warning diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index 196fdda0006..c87ef643a7f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -275,7 +275,7 @@ hermes uninstall Uninstall Hermes /config Show config (CLI) /model [name] Show or change model /personality [name] Set personality -/reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|show|hide) +/reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|max|ultra|show|hide) /verbose Cycle: off → new → all → verbose /voice [on|off|tts] Voice mode /yolo Toggle approval bypass @@ -481,10 +481,10 @@ hermes config set privacy.redact_pii false # 禁用(默认) ### 命令审批提示 -默认情况下(`approvals.mode: manual`),Hermes 在运行被标记为破坏性的 shell 命令(`rm -rf`、`git reset --hard` 等)之前会提示用户。模式如下: +默认情况下(`approvals.mode: smart`),Hermes 会让辅助 LLM 评估被标记为破坏性的 shell 命令(`rm -rf`、`git reset --hard` 等)。模式如下: -- `manual` — 始终提示(默认) -- `smart` — 使用辅助 LLM 自动批准低风险命令,对高风险命令提示 +- `smart` — 低风险命令仅批准一次,高风险命令拒绝,不确定时提示(默认) +- `manual` — 始终提示 - `off` — 跳过所有审批提示(等同于 `--yolo`) ```bash diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 96009ade316..b829b00eb03 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-07-08T18:59:16Z", + "updated_at": "2026-07-09T18:38:59Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -32,6 +32,30 @@ "id": "anthropic/claude-haiku-4.5", "description": "" }, + { + "id": "openai/gpt-5.6-sol", + "description": "" + }, + { + "id": "openai/gpt-5.6-sol-pro", + "description": "" + }, + { + "id": "openai/gpt-5.6-terra", + "description": "" + }, + { + "id": "openai/gpt-5.6-terra-pro", + "description": "" + }, + { + "id": "openai/gpt-5.6-luna", + "description": "" + }, + { + "id": "openai/gpt-5.6-luna-pro", + "description": "" + }, { "id": "openai/gpt-5.5", "description": "" @@ -60,10 +84,6 @@ "id": "x-ai/grok-4.5", "description": "" }, - { - "id": "x-ai/grok-4.3", - "description": "" - }, { "id": "deepseek/deepseek-v4-pro", "description": "" @@ -172,6 +192,24 @@ { "id": "anthropic/claude-haiku-4.5" }, + { + "id": "openai/gpt-5.6-sol" + }, + { + "id": "openai/gpt-5.6-sol-pro" + }, + { + "id": "openai/gpt-5.6-terra" + }, + { + "id": "openai/gpt-5.6-terra-pro" + }, + { + "id": "openai/gpt-5.6-luna" + }, + { + "id": "openai/gpt-5.6-luna-pro" + }, { "id": "openai/gpt-5.5" }, @@ -193,9 +231,6 @@ { "id": "x-ai/grok-4.5" }, - { - "id": "x-ai/grok-4.3" - }, { "id": "deepseek/deepseek-v4-pro" },