From c4efb4ea843871621bf27038cff0a10998a9ee61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 06:23:24 +0000 Subject: [PATCH] =?UTF-8?q?sync:=20merge=20upstream=20v0.51.310=20(v0.51.2?= =?UTF-8?q?96=E2=80=93v0.51.310)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges upstream tags v0.51.296 through v0.51.310. Changes include: remote-workspace blocked-root security fix, terminal remote-backend guard, custom provider live model probe, update-wait process-identity check, dormant unified-SessionDB adapter, Docker GPU opt-in image path, cron/config/git hardening, mobile touch fixes (iOS rename + long-press project chips), update CSRF hardening + onboarding gate, performance fix for large tool/log payloads, and reconnect replay live-tool-card restore. Conflicts resolved: - CHANGELOG.md: union/additive — kept fork [Unreleased] entries above upstream new release entries (v0.51.295–v0.51.310). - tests/test_update_banner_fixes.py: additive — took upstream's new server- identity tests; added missing `import subprocess` (upstream tests used it without importing; pre-existing omission in upstream's new test block). All pre-existing failures on master (git-signing env issue affecting test_workspace_git/worktree tests, and several other container-environment failures) remain unchanged. Auth regression suite (test_issue2_*) green. --- .gitignore | 2 + CHANGELOG.md | 95 ++++ Dockerfile | 20 + api/agent_sessions.py | 238 +++++++-- api/config.py | 46 +- api/models.py | 21 +- api/routes.py | 471 ++++++++++++++---- api/streaming.py | 98 ++-- api/terminal.py | 30 ++ api/updates.py | 13 + api/webui_session_db.py | 250 ++++++++++ api/workspace.py | 2 + api/workspace_git.py | 14 +- docker_init.bash | 25 +- docs/architecture/unified-session-db.md | 98 ++++ docs/docker.md | 75 +++ static/boot.js | 2 +- static/commands.js | 20 +- static/i18n.js | 11 + static/panels.js | 19 +- static/sessions.js | 92 +++- static/style.css | 7 +- static/terminal.js | 57 ++- static/ui.js | 106 +++- tests/browser_smoke.py | 1 + tests/conftest.py | 1 + tests/test_api_timeout.py | 2 +- tests/test_byok_model_dropdown.py | 4 +- tests/test_ctl_script.py | 10 +- tests/test_docker_gpu_runtime_docs.py | 96 ++++ tests/test_embedded_workspace_terminal.py | 102 ++++ tests/test_gateway_sync.py | 138 +++++ tests/test_inflight_stream_reuse.py | 53 +- tests/test_issue1796_error_toasts.py | 10 +- ..._issue1896_context_length_fallback_args.py | 22 +- ...e3717_context_length_provider_overrides.py | 198 ++++++++ ...test_issue3718_live_models_custom_probe.py | 193 +++++++ tests/test_merge_key_tool_calls.py | 41 ++ tests/test_onboarding_network.py | 19 +- ...pr1370_lineage_metadata_perf_and_orphan.py | 166 ++++++ tests/test_project_chip_ui.py | 63 +++ tests/test_regressions.py | 4 +- tests/test_remote_terminal_workspace.py | 11 + tests/test_security_review_fixes.py | 223 +++++++++ tests/test_terminal_zombie_reaper.py | 124 +++++ tests/test_tls_support.py | 4 +- tests/test_ui_tool_call_cleanup.py | 3 + tests/test_update_banner_fixes.py | 427 ++++++++++++++++ tests/test_webui_session_db_adapter.py | 162 ++++++ tests/test_workspace_git.py | 69 +++ 50 files changed, 3697 insertions(+), 261 deletions(-) create mode 100644 api/webui_session_db.py create mode 100644 docs/architecture/unified-session-db.md create mode 100644 tests/test_docker_gpu_runtime_docs.py create mode 100644 tests/test_issue3717_context_length_provider_overrides.py create mode 100644 tests/test_issue3718_live_models_custom_probe.py create mode 100644 tests/test_security_review_fixes.py create mode 100644 tests/test_terminal_zombie_reaper.py create mode 100644 tests/test_webui_session_db_adapter.py diff --git a/.gitignore b/.gitignore index b79aabbbd12..955c649a13d 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ docs/* !docs/ui-ux/** !docs/rfcs/ !docs/rfcs/** +!docs/architecture/ +!docs/architecture/** # Local-only AI assistant context — never committed even under docs/. docs/AGENTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 95234f01ff3..50029c489a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,101 @@ ### Fixed (fork) - `sync-upstream.yml` no longer fails at the push step when an upstream tag modifies a file under `.github/workflows/`. The default `GITHUB_TOKEN` cannot push workflow-file changes (GitHub rejects the push without the `workflows` permission scope), and this fork runs its own CI rather than tracking upstream's. The workflow now reverts any upstream edits to `.github/workflows/*` back to our `master` version, folds the revert into the merge commit, and surfaces the dropped files as a `::notice` and in the PR body. Without this, every sync that touches an upstream workflow file (e.g. v0.51.189, which adds a ruff lint job to `tests.yml`) aborted before opening a PR. + +## [v0.51.310] — 2026-06-07 — Release JZ (stage-3760 — long-press project chips to delete on touch) + +### Fixed +- **Project chips can now be managed on touch devices.** The project filter chips in the sidebar exposed Rename / Color / Delete only through the right-click context menu, which has no touch equivalent — so on phones/tablets there was no way to delete a project and the list grew forever. A 500ms long-press now opens the same context menu (mirroring the existing session-item long-press): a >10px drag cancels it, a short tap still filters, and the synthetic click after the press is suppressed. The chip shows accent + slight-scale feedback while held, and native callout/selection is disabled so it doesn't compete with the gesture. Includes a multi-touch correctness fix (a second finger / stray touchstart can no longer orphan the press timer and pop the menu after the gesture was cancelled). Verified live: long-press opens the menu once, short tap doesn't, and the multi-touch-then-cancel case no longer fires. (#3760, @reinocheong) + +## [v0.51.309] — 2026-06-07 — Release JY (stage-a5b — restore reconnect replays live tool cards) + +### Fixed +- **Switching back to a long-running session during reconnect no longer drops the live tool cards.** After the #3401 live-to-final redesign (epic #3400), when a running session was restored from its in-memory live-turn snapshot and then reattached to the SSE stream, the restore-success path skipped replaying persisted live tool calls — so you'd return to restored live text and thinking but an empty Worklog until a later SSE event or the final render rebuilt the turn. The persisted tool-card replay now also runs on the restore-success + reconnect path (not only the fallback path). To avoid double-painting, an unkeyed persisted tool is skipped when the restored snapshot already shows tool rows, `appendLiveToolCard()` dedupes across all known tool-id aliases (`tid`/`id`/`tool_call_id`/`tool_use_id`/`call_id`), and both replay sites pass the session/stream ownership guard so a switched-away session can't repaint stale tools. (#3763 fixes #3707, @franksong2702) + +## [v0.51.308] — 2026-06-07 — Release JX (consistency — gate onboarding-complete like its siblings) + +### Changed +- **`/api/onboarding/complete` now applies the same local-network gate as the other onboarding endpoints, for consistency.** `oauth/start`, `setup`, and `probe` already refused unauthenticated non-local requests; `complete` (which persists `onboarding_completed=true` to hide the first-run wizard) did not, so all four onboarding mutators now behave alike — `complete` returns 403 unless the request is local, auth is enabled, or `HERMES_WEBUI_ONBOARDING_OPEN=1`. This is a consistency tidy, **not** a security boundary: the same flag is still settable via `POST /api/settings` (one of ~25 settings keys broadly writable on a passwordless public bind), and the real protection for a public bind is to run with authentication or refuse the bind. Low impact regardless — the flag only toggles a UI wizard, not credentials or access. (#3765, surfaced by the #3758 release gate) + +## [v0.51.307] — 2026-06-06 — Release JW (stage-a3 — onboarding forwarded-IP spoof fix + update-check CSRF hardening) + +### Security +- **Unauthenticated first-run onboarding no longer trusts spoofable forwarded IP headers.** The local-network gate that lets onboarding run without a password now ignores `X-Forwarded-For` / `X-Real-IP` by default (a direct client can set them to a private address to bypass the gate), trusting them only when `HERMES_WEBUI_TRUST_FORWARDED_FOR=1` is explicitly set behind a trusted reverse proxy — and then using the rightmost, proxy-appended hop. When forwarded headers are present but untrusted, the request is treated as coming through a proxy and is denied (the raw socket is the proxy's, not the client's), so a public client behind any reverse proxy can't be read as local. **Reverse-proxy deployments that run onboarding without a password must set `HERMES_WEBUI_TRUST_FORWARDED_FOR=1` (or `HERMES_WEBUI_ONBOARDING_OPEN=1`).** Direct loopback clients are unaffected. (#3758, @fantasticsquirrel) +- **Update checks that hit the network/git are now CSRF-safe.** `GET /api/updates/check` is cache-only (no network or git mutation), so a state-changing update fetch can't be triggered by a bare cross-site navigation; the forced refresh moved to `POST /api/updates/check {force:true}`. Docker init env logging also masks `PASSWORD`/`SECRET`/`CREDENTIAL`/`COOKIE`/`SESSION` key names in addition to `TOKEN`/`API`/`KEY`. (Minor behavior tightening: `HERMES_WEBUI_ONBOARDING_OPEN` now only bypasses for canonical truthy values `1`/`true`/`yes`/`on` rather than any non-empty value.) (#3758, @fantasticsquirrel) + +## [v0.51.306] — 2026-06-06 — Release JV (stage-a2 — branchy compression lineage resolves to the freshest tip) + +### Fixed +- **A conversation no longer looks missing or stale after compaction when its compression lineage branched.** When a compression parent had multiple continuation-looking children — which happens when a stale segment is resumed after a newer compressed branch already exists — the sidebar projection followed only the newest *direct* child, so it could surface a dead-end branch and hide the deeper branch that actually has the latest activity. The projection now walks every reachable continuation descendant and selects the freshest *messageful* tip by `(last_activity, depth)`, and `read_session_lineage_metadata()` exposes the same canonical `_lineage_tip_id` so the sidebar collapse and the import projection agree on which branch is live. Older/minimal `state.db` files stay compatible — `source`, `message_count`, and the `messages` table (including schemas with no `timestamp` column or an ISO-8601 *text* timestamp) are all treated as optional and can no longer collapse the lineage metadata. (#3751, @ai-ag2026) + +## [v0.51.305] — 2026-06-06 — Release JU (stage-p2b — dormant unified-SessionDB adapter groundwork) + +### Changed +- **Added the first, dormant slice of the unified-SessionDB migration (no runtime behavior change).** A new `SessionDB`-shaped adapter over the existing WebUI JSON session store lands behind an experimental flag (`experimental.unified_session_db`, default `false`). No runtime session call site is rewired — `is_unified_session_db_enabled()` has no live callers — so WebUI persistence is byte-for-byte unchanged until a later migration PR deliberately opts in. This is intentionally the lowest-risk first step toward expressing WebUI session access behind a stable adapter contract before any CLI/WebUI storage unification. Ships the adapter, the dormant config flag, an architecture doc, and adapter tests. (#3720 advances #3383, @rodboev) + +## [v0.51.304] — 2026-06-06 — Release JT (stage-p2a — un-held terminal reaper fix + opt-in Docker GPU image) + +### Fixed +- **Embedded-terminal descendants are now reaped without clobbering unrelated subprocess exit codes.** Terminal descendants reparented to the WebUI process could linger as zombies. The reaper now runs `os.waitpid(-terminal_pgid, WNOHANG)` scoped to the terminal's own process group (terminals spawn with `start_new_session=True`, so the child's PID is its PGID) instead of a process-wide `waitpid(-1)` — the latter could reap an unrelated WebUI subprocess (update/git/provider) before its owner called `.wait()`, silently coercing that child's real exit code to `0`. Bounded by a 64-iteration limit, lock-guarded, and run on both reader cleanup and terminal close. (#3725 fixes #2577, @rodboev) + +### Added +- **Optional GPU runtime image path.** The default image stays CPU-only. Building with `--build-arg INSTALL_GPU_LIBS=1` installs VA-API user-space libraries for users passing through host GPU devices, and `docker_init.bash` now preserves Docker `--group-add` supplemental device groups (e.g. `render`/`video` for `/dev/dri` access) when dropping privileges to the runtime user. With the default `INSTALL_GPU_LIBS=0` this is a no-op. (#3721 addresses #3243, @rodboev) + +## [v0.51.303] — 2026-06-06 — Release JS (stage-p1a — low-risk fixes: cron toggle, config var expansion, git-discard hardening) + +### Fixed +- **Clicking an already-open cron run row now collapses it instead of re-fetching.** `_loadRunContent()` only ever expanded a run's output, so tapping an open row issued a pointless re-fetch. It now toggles — an open row collapses (clearing its expansion state and resetting the toggle button) and returns early. (#3732, @mysoul12138) + +### Added +- **`config.yaml` now expands `${VAR}` references against the environment at load time in the WebUI.** hermes-agent already supported `${ENV_VAR}` substitution, but the WebUI's own config loader stored the raw dict, leaving literal `${...}` strings in values. Both WebUI config load paths now recursively expand `${VAR}` from `os.environ`; an unset variable is left untouched (`${VAR}` preserved). (#3736, @Carry00) + +### Security +- **`git_discard(delete_untracked=true)` now deletes untracked files through the anchored workspace helpers, closing a validation-to-use symlink-swap window.** The discard previously validated the path with `safe_resolve_ws` and then deleted with raw `shutil.rmtree` / `Path.unlink`, so a workspace-controlled path component swapped to a symlink between validation and deletion could escape the workspace. Untracked deletes now go through `rmtree_anchored` / `unlink_anchored` (rejecting a swapped component at delete time) while preserving the prior tolerance for a benign concurrent-removal race. (#3702, @Hinotoi-agent) + +## [v0.51.302] — 2026-06-06 — Release JR (stage-brick — mobile/iOS breakage + large-session perf hotfixes) + +### Fixed +- **Hidden notification toasts no longer block taps on mobile.** The `.toast` container stayed `pointer-events:auto` while hidden (`opacity:0`), and its fixed padding sat over the profile action buttons at the top of the mobile content view — so taps on Activate/Delete and similar controls were silently eaten by an invisible element. The toast is now `pointer-events:none` when hidden and only becomes interactive on `.toast.show`. (#3735, @timlawrenz) +- **Renaming a conversation now works on iOS Safari.** iOS has no Enter key on the soft keyboard; tapping "Done" fires `blur`, and the old `onblur` handler *cancelled* the rename — so a mobile rename could never be saved. Blur now commits the rename (Escape still explicitly cancels), matching the more natural desktop expectation that typing a name then clicking away saves it. The same blur-saves fix applies to project create/rename, guarded by a `_finishDone` latch so the blur and the API callback can't double-fire. (#3729, @reinocheong) + +### Performance +- **Loading a session with very large tool/log payloads no longer stalls the whole WebUI for many seconds.** `_matching_visible_duplicate()` eagerly casefolded and regex-tokenized every visible message key — including multi-megabyte tool outputs — on each duplicate probe, so `/api/session` could take 10s+ and block the sidebar's `/api/sessions` for ~19s. Loose-content normalization is now lazy and cached, and substring/fuzzy matching is skipped for non-exact payloads larger than 200KB; exact visible-key matches still short-circuit before the guard. (#3730, @alvistar) + +## [v0.51.301] — 2026-06-06 — Release JQ (stage-3710 — hide test-helper console windows on Windows) + +### Changed +- **Developer experience (Windows):** the test suite's long-lived helper subprocesses (main test server, browser-smoke server, TLS helper, and the `ctl` test helpers) now spawn with `CREATE_NO_WINDOW` on Windows, so a local `pytest` run no longer pops up several focus-stealing console windows. Windows-only (`sys.platform == "win32"` guard); no behavior change on macOS/Linux. (#3710 fixes #3706, @rodboev) + +## [v0.51.300] — 2026-06-06 — Release JP (stage-3726 — context-length indicator honors provider per-model overrides) + +### Fixed +- **The context-length indicator now honors per-model `context_length` overrides configured under a specific provider.** A `providers..models..context_length` override (standard provider, no `base_url`) was invisible to the session model's context resolver, so the usage indicator showed and persisted the wrong window — and could trip auto-compression at the wrong threshold. The resolver now collects the effective provider config, base URL, and custom-provider metadata across all three paths (route load, session save, live SSE usage), resolving provider-scoped overrides by provider-name match and forwarding them as `config_context_length` (which the agent returns before any base-URL-gated probe). `custom_providers`-with-`base_url` per-model overrides continue to resolve as before. (#3726 fixes #3717, @rodboev) + +## [v0.51.299] — 2026-06-06 — Release JO (stage-3713 — update flow waits for a genuinely new server instance) + +### Fixed +- **"Update now" no longer reloads onto the old server process (or shows a premature error toast).** The post-update reload waited for the first healthy `/health` response, but didn't confirm the server *instance* had actually changed — so on a slow restart the client could reload too early and land back on the old process, or surface an upgrade error that resolved itself after a manual refresh. The update trigger now captures a baseline server identity (from `/health`'s already-present `server_started_at` / `SERVER_START_TIME`) and `_waitForServerThenReload()` only reloads once it observes a *changed* identity. (#3713 fixes #3619, @rodboev) + +## [v0.51.298] — 2026-06-06 — Release JN (stage-3719 — live model probe for custom providers with model config) + +### Fixed +- **`/api/models/live` now probes the upstream `/v1/models` endpoint for custom providers even when a `model:` is configured.** When a `custom_providers` entry had a `model:` field, the live handler added that config model to the `ids` list *before* the `if not ids:` guard that triggers the live fetch — so the probe was skipped and Settings' "refresh models" returned only the single config entry instead of the full upstream catalog (e.g. a LiteLLM proxy exposing 50+ models showed just one). Config models are now collected separately and the live fetch always runs for custom providers; live results take priority and config entries are merged in as a fallback (and used as the full list if the fetch fails). The static `/api/models` endpoint already handled this correctly. (#3719 fixes #3718, @DanielMaly) + +## [v0.51.297] — 2026-06-06 — Release JM (stage-3711 — terminal remote-backend guard) + +### Fixed +- **The embedded `/terminal` no longer fails late and opaquely on remote terminal backends.** With an SSH/Docker terminal backend, `/api/terminal/start` still funneled the request into the local PTY spawn path and failed deep inside `start_terminal()`. The route now rejects non-local backends deliberately at the boundary and surfaces the same state early in the `/terminal` slash-command UX, so users get a clear "not available for this backend" signal instead of an opaque failure. (#3711 fixes #3673, @rodboev) + +## [v0.51.296] — 2026-06-06 — Release JL (stage-3731 — remote-workspace blocked-root security fix) + +### Security +- **Remote-terminal workspace resolution now rejects blocked system roots.** `_remote_terminal_workspace_candidate()` returned early for paths under the configured remote terminal cwd *before* the blocked-root guard ran, so an SSH/remote terminal profile whose target-side cwd was a system directory (e.g. `/etc`) could have that root accepted as a local workspace — after which workspace file helpers (which treat `s.workspace` as a local `Path`) could read local system files. The blocked-root guard now runs for both the candidate and the base path, so registration and trusted-workspace resolution reject these roots consistently. (#3731, @Hinotoi-agent) + +## [v0.51.295] — 2026-06-06 — Release JK (stage-3739/3742 — model-pick revert fix + session-status revert) + +### Fixed +- **The composer model picker no longer silently reverts your selection on send.** When you explicitly picked a model whose family differed from the active profile's provider (e.g. a `gpt-*` model under an `anthropic`-bound profile), the server's profile-aware resolution (v0.51.290, #3448) rewrote it to the profile default and the dropdown snapped back with no warning. An explicit pick is now honored across both resolution paths (the profile-provider branch and the legacy bare-prefix branch), and if the model genuinely must change (a real provider mismatch) a toast explains it instead of silently swapping. The legitimate stale-session repair path is preserved. (#3739 fixes #3737, @someaka) + ### Removed - **Reverted the manual per-session status labels (Todo / In Progress / Done).** The feature added in v0.51.284 (#3570) stored the chosen status only in browser `localStorage`, keyed by session id, with no server-side backing — so labels silently did not persist across browsers or devices (a user who labeled sessions on one machine saw none of them after switching to a laptop). It also rendered the three statuses as flat top-level entries in the session context menu alongside Copy/Rename/Pin/etc., which crowded the root menu. Removed entirely for now (JS state + cycle logic, context-menu entries, sidebar badge render, CSS, and all locale strings); the feature can be reintroduced later with proper server-side persistence and a less intrusive menu treatment. (reverts #3570) diff --git a/Dockerfile b/Dockerfile index aafb9858171..20943c15acc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,26 @@ RUN apt-get update -y --fix-missing --no-install-recommends \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +# Optional GPU user-space acceleration libraries for users who pass through +# host GPU devices. The default image remains CPU-only. +ARG INSTALL_GPU_LIBS=0 +RUN if [ "$INSTALL_GPU_LIBS" = "1" ]; then \ + apt-get update -y --fix-missing --no-install-recommends \ + && apt-get install -y --no-install-recommends \ + libva2 \ + vainfo \ + mesa-va-drivers \ + && if apt-cache show intel-media-va-driver-non-free >/dev/null 2>&1; then \ + apt-get install -y --no-install-recommends intel-media-va-driver-non-free; \ + else \ + echo "intel-media-va-driver-non-free is not available from the configured Debian repositories; skipping Intel non-free VA-API driver."; \ + fi \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/*; \ + else \ + echo "Skipping optional GPU user-space acceleration libraries (INSTALL_GPU_LIBS=0)."; \ + fi + # UTF-8 RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 ENV LANG=en_US.utf8 diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 1da2625d7fa..bc39649fc52 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -120,6 +120,26 @@ def _as_positive_int(value) -> int: return 0 +def _as_score(*values) -> float: + """First numerically-coercible value as a float, else 0.0. + + Used to score lineage tips by recency. ``last_message_at`` comes from + ``MAX(timestamp)`` and is normally a numeric epoch, but older/non-standard + state.db schemas can store an ISO-8601 *text* timestamp. Rather than letting + a non-numeric value raise ValueError (which previously escaped the DB + try-block and dropped all lineage metadata), fall through to the next + candidate (e.g. ``started_at``). + """ + for value in values: + if value in (None, ""): + continue + try: + return float(value) + except (TypeError, ValueError): + continue + return 0.0 + + def _count_user_turns(row: dict) -> int: user_turns = row.get("actual_user_message_count") if user_turns is None: @@ -279,23 +299,50 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]: children.sort(key=lambda row: row.get('started_at') or 0, reverse=True) def compression_tip(row: dict) -> tuple[dict | None, int]: - current = row - seen = {row['id']} + """Return the freshest importable continuation descendant for ``row``. + + Compression parents can have multiple continuation-looking children when + a stale segment is resumed after a newer compressed branch already + exists. Picking the newest *direct* child can hide the branch whose + deeper descendant has the actual latest activity. Walk all reachable + continuation descendants and select by real message activity instead. + """ latest_importable = row if (row.get('actual_message_count') or 0) > 0 else None - segment_count = 1 - for _ in range(len(rows_by_id) + 1): - candidates = [ - child for child in children_by_parent.get(current['id'], []) - if child['id'] not in seen and _is_continuation_session(current, child) - ] - if not candidates: - return latest_importable, segment_count - current = candidates[0] - seen.add(current['id']) + segment_count = 0 + best_depth = 1 + best_score = ( + _as_score(latest_importable.get('last_activity'), latest_importable.get('started_at')) + if latest_importable + else 0 + ) + stack: list[tuple[dict, int]] = [(row, 1)] + seen: set[str] = set() + + while stack: + current, depth = stack.pop() + current_id = current.get('id') + if not current_id or current_id in seen: + continue + seen.add(current_id) segment_count += 1 - if (current.get('actual_message_count') or 0) > 0: + + current_score = _as_score(current.get('last_activity'), current.get('started_at')) + if ( + (current.get('actual_message_count') or 0) > 0 + and (current_score > best_score or (current_score == best_score and depth >= best_depth)) + ): latest_importable = current - return latest_importable, segment_count + best_depth = depth + best_score = current_score + for child in children_by_parent.get(current_id, []): + child_id = child.get('id') + if not child_id or child_id in seen: + continue + if not _is_continuation_session(current, child): + continue + stack.append((child, depth + 1)) + + return latest_importable, max(segment_count, 1) projected = [] for row in rows: @@ -338,7 +385,7 @@ def compression_tip(row: dict) -> tuple[dict | None, int]: projected.append(merged) projected.sort( - key=lambda row: row.get('last_activity') or row.get('started_at') or 0, + key=lambda row: _as_score(row.get('last_activity'), row.get('started_at')), reverse=True, ) return projected @@ -346,7 +393,7 @@ def compression_tip(row: dict) -> tuple[dict | None, int]: def read_importable_agent_session_rows( db_path: Path, - limit: int = 200, + limit: int | None = 200, log=None, exclude_sources: tuple[str, ...] | None = ("cron", "webui"), ) -> list[dict]: @@ -684,6 +731,8 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st if 'parent_session_id' not in session_cols or 'end_reason' not in session_cols: return {} session_source_expr = _optional_col('session_source', session_cols) + source_expr = _optional_col('source', session_cols) + message_count_expr = _optional_col('message_count', session_cols, '0') # Scoped fetch via PRIMARY KEY + idx_sessions_parent rather than a # full table scan. The sessions table grows unbounded over time # (1000+ rows is normal, 10000+ for power users), and this function @@ -692,7 +741,9 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st # # Fetch the wanted ids first, then chase parent_session_id chains # in batches until no new ids appear. Each batch hits PRIMARY KEY - # so it's effectively O(N) lookups. + # so it's effectively O(N) lookups. Then walk continuation children + # from the materialized ancestors so branchy compression lineages can + # mark the real freshest tip, not just the newest direct sibling. # # IN-clause is chunked to 500 to stay under SQLITE_MAX_VARIABLE_NUMBER # on older sqlite (Python 3.9 ships sqlite 3.31 which defaults to 999; @@ -717,7 +768,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st placeholders = ','.join('?' * len(chunk)) cur.execute( f""" - SELECT s.id, s.source, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason + SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr} FROM sessions s WHERE s.id IN ({placeholders}) """, @@ -730,9 +781,137 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st parent_id = rows.get(sid, {}).get('parent_session_id') if parent_id and parent_id not in rows and parent_id not in to_fetch: to_fetch.add(parent_id) + + # Fetch descendants from the discovered ancestors using the parent + # index. This keeps the sidebar read scoped while still giving the + # collapse metadata enough information to choose the active branch. + to_expand = set(rows) + expanded: set[str] = set() + for _hop in range(20): + frontier = [sid for sid in to_expand if sid not in expanded] + if not frontier: + break + to_expand = set() + for i in range(0, len(frontier), IN_CHUNK): + chunk = frontier[i:i + IN_CHUNK] + placeholders = ','.join('?' * len(chunk)) + cur.execute( + f""" + SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr} + FROM sessions s + WHERE s.parent_session_id IN ({placeholders}) + """, + chunk, + ) + for row in cur.fetchall(): + child = dict(row) + rows[child['id']] = child + parent_id = child.get('parent_session_id') + parent = rows.get(str(parent_id)) if parent_id else None + if parent and child['id'] not in expanded and _is_continuation_session(parent, child): + to_expand.add(child['id']) + expanded.update(frontier) + + message_stats: dict[str, dict] = {} + cur.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'messages'") + has_messages_table = cur.fetchone() is not None + # Older/minimal state.db schemas can have a `messages` table WITHOUT a + # `timestamp` column (or with a non-numeric one). Detect the columns + # rather than gating on table existence alone: require `session_id`, + # and only select MAX(timestamp) when that column is actually present + # so the query can't raise and collapse the whole lineage metadata. + messages_has_session_id = False + messages_has_timestamp = False + if has_messages_table: + cur.execute("PRAGMA table_info(messages)") + _message_cols = {row[1] for row in cur.fetchall()} + messages_has_session_id = 'session_id' in _message_cols + messages_has_timestamp = 'timestamp' in _message_cols + use_messages_query = has_messages_table and messages_has_session_id + row_ids = list(rows) + if use_messages_query: + last_at_expr = "MAX(timestamp) AS last_message_at" if messages_has_timestamp else "NULL AS last_message_at" + for i in range(0, len(row_ids), IN_CHUNK): + chunk = row_ids[i:i + IN_CHUNK] + placeholders = ','.join('?' * len(chunk)) + cur.execute( + f""" + SELECT session_id, COUNT(*) AS actual_message_count, {last_at_expr} + FROM messages + WHERE session_id IN ({placeholders}) + GROUP BY session_id + """, + chunk, + ) + for row in cur.fetchall(): + message_stats[row['session_id']] = dict(row) + for sid, row in rows.items(): + stats = message_stats.get(sid) or {} + if use_messages_query: + row['actual_message_count'] = int(stats.get('actual_message_count') or 0) + else: + row['actual_message_count'] = int(row.get('message_count') or 0) + row['last_message_at'] = stats.get('last_message_at') except Exception: return {} + children_by_parent: dict[str, list[dict]] = {} + for row in rows.values(): + parent_id = row.get('parent_session_id') + if parent_id: + children_by_parent.setdefault(parent_id, []).append(row) + + def continuation_root_and_depth(sid: str) -> tuple[str, int]: + root_id = sid + current_id = sid + depth = 1 + seen = {sid} + while True: + current = rows.get(current_id) + raw_parent_id = current.get('parent_session_id') if current else None + parent_id = str(raw_parent_id) if raw_parent_id else '' + if not parent_id: + break + parent = rows.get(parent_id) + if not parent or parent_id in seen: + break + if not _is_continuation_session(parent, current): + break + root_id = parent_id + current_id = parent_id + seen.add(parent_id) + depth += 1 + return root_id, depth + + def freshest_continuation_tip(root_id: str) -> tuple[str, int]: + best_id = root_id + best_depth = 1 + segment_count = 0 + best_score = _as_score(rows.get(root_id, {}).get('last_message_at'), rows.get(root_id, {}).get('started_at')) + stack: list[tuple[str, int]] = [(root_id, 1)] + seen: set[str] = set() + while stack: + current_id, depth = stack.pop() + if current_id in seen: + continue + seen.add(current_id) + current = rows.get(current_id) + if not current: + continue + segment_count += 1 + actual_count = int(current.get('actual_message_count') or 0) + score = _as_score(current.get('last_message_at'), current.get('started_at')) + if actual_count > 0 and (score > best_score or (score == best_score and depth >= best_depth)): + best_id = current_id + best_depth = depth + best_score = score + for child in children_by_parent.get(current_id, []): + if _is_continuation_session(current, child): + stack.append((child['id'], depth + 1)) + + return best_id, max(segment_count, best_depth) + + lineage_tip_cache: dict[str, tuple[str, int]] = {} metadata: dict[str, dict] = {} for sid in wanted: row = rows.get(sid) @@ -770,26 +949,15 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st entry['_parent_lineage_root_id'] = parent_root continue - root_id = sid - current_id = sid - segment_count = 1 - seen = {sid} - while True: - current = rows.get(current_id) - parent_id = current.get('parent_session_id') if current else None - parent = rows.get(parent_id) if parent_id else None - if not parent or parent_id in seen: - break - if not _is_continuation_session(parent, current): - break - root_id = parent_id - current_id = parent_id - seen.add(parent_id) - segment_count += 1 + root_id, segment_count = continuation_root_and_depth(sid) if root_id != sid: entry = metadata.setdefault(sid, {}) entry['_lineage_root_id'] = root_id - entry['_compression_segment_count'] = segment_count + if root_id not in lineage_tip_cache: + lineage_tip_cache[root_id] = freshest_continuation_tip(root_id) + tip_id, tip_depth = lineage_tip_cache[root_id] + entry['_lineage_tip_id'] = tip_id + entry['_compression_segment_count'] = max(segment_count, tip_depth) return metadata diff --git a/api/config.py b/api/config.py index 0b3230c7ef4..a2e6d0ec910 100644 --- a/api/config.py +++ b/api/config.py @@ -249,6 +249,18 @@ def _discover_python(agent_dir: Path) -> str: _HERMES_FOUND = False # ── Config file (reloadable -- supports profile switching) ────────────────── + +def _expand_env_vars(obj): + """Recursively expand ${VAR} references in config values using os.environ.""" + if isinstance(obj, str): + return re.sub(r"\${([^}]+)}", lambda m: os.environ.get(m.group(1), m.group(0)), obj) + if isinstance(obj, dict): + return {k: _expand_env_vars(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_expand_env_vars(item) for item in obj] + return obj + + _cfg_cache = {} _cfg_lock = threading.Lock() _cfg_mtime: float = 0.0 # last known mtime of config.yaml; 0 = never loaded @@ -308,6 +320,22 @@ def _get_config_path() -> Path: _WEBUI_SESSION_SAVE_MODES = {"deferred", "eager"} _DEFAULT_WEBUI_SESSION_SAVE_MODE = "deferred" +_DEFAULT_EXPERIMENTAL_CONFIG = { + # Dormant first slice for the unified SessionDB migration. Runtime WebUI + # session call sites must continue using the existing JSON paths unless a + # later PR deliberately enables and wires this flag. + "unified_session_db": False, +} + + +def _apply_config_defaults(config_data: dict) -> None: + """Populate documented default-only config keys in-place.""" + experimental = config_data.get("experimental") + if not isinstance(experimental, dict): + experimental = {} + config_data["experimental"] = experimental + for key, value in _DEFAULT_EXPERIMENTAL_CONFIG.items(): + experimental.setdefault(key, value) def get_config() -> dict: @@ -355,6 +383,19 @@ def get_webui_session_save_mode(config_data: dict | None = None) -> str: return _DEFAULT_WEBUI_SESSION_SAVE_MODE +def is_unified_session_db_enabled(config_data: dict | None = None) -> bool: + """Return the dormant unified-session-db feature flag. + + The default is intentionally false so adding the JSON adapter cannot change + runtime persistence until a later migration PR switches call sites. + """ + active_cfg = config_data if isinstance(config_data, dict) else cfg + experimental = active_cfg.get("experimental", {}) if isinstance(active_cfg, dict) else {} + if not isinstance(experimental, dict): + return False + return experimental.get("unified_session_db") is True + + def reload_config() -> None: """Reload config.yaml from the active profile's directory.""" global _cfg_mtime, _cfg_path, _cfg_fingerprint @@ -372,13 +413,14 @@ def reload_config() -> None: if config_path.exists(): loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8")) if isinstance(loaded, dict): - _cfg_cache.update(loaded) + _cfg_cache.update(_expand_env_vars(loaded)) try: _cfg_mtime = Path(config_path).stat().st_mtime except OSError: _cfg_mtime = 0.0 except Exception: logger.debug("Failed to load yaml config from %s", config_path) + _apply_config_defaults(_cfg_cache) _cfg_fingerprint = _fingerprint_config(_cfg_cache) # Bust the models cache so the next request sees fresh config values. # Only delete the disk cache when config has actually changed -- not on @@ -399,7 +441,7 @@ def _load_yaml_config_file(config_path: Path) -> dict: return {} try: loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8")) - return loaded if isinstance(loaded, dict) else {} + return _expand_env_vars(loaded) if isinstance(loaded, dict) else {} except Exception: logger.debug("Failed to parse yaml config from %s", config_path) return {} diff --git a/api/models.py b/api/models.py index b322feb3b83..e962009e665 100644 --- a/api/models.py +++ b/api/models.py @@ -4040,7 +4040,6 @@ def _session_message_visible_key(msg: dict): def _build_visible_duplicate_lookup(visible_keys: set[tuple]) -> dict: by_role = {} - loose_by_key = {} for key in visible_keys: try: role = key[0] @@ -4050,8 +4049,10 @@ def _build_visible_duplicate_lookup(visible_keys: set[tuple]) -> dict: if not content: continue by_role.setdefault(role, []).append(key) - loose_by_key[key] = _loose_session_message_content(content) - return {"keys": visible_keys, "by_role": by_role, "loose_by_key": loose_by_key} + # Keep loose_by_key lazy. Some transcripts contain multi-megabyte tool + # outputs; eagerly casefolding + regex-tokenizing every visible key on every + # duplicate probe made /api/session take 10s+ and blocked /api/sessions. + return {"keys": visible_keys, "by_role": by_role, "loose_by_key": {}} def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple], lookup: dict | None = None): @@ -4064,16 +4065,28 @@ def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple], lo if lookup is None: lookup = _build_visible_duplicate_lookup(visible_keys) loose_content = None + loose_by_key = lookup.setdefault("loose_by_key", {}) for existing_key in lookup.get("by_role", {}).get(role, []): existing_role = existing_key[0] existing_content = existing_key[1] if len(existing_key) > 1 else "" if role != existing_role or not existing_content: continue + # Exact visible-key equality was checked above. For very large payloads + # (tool logs / request dumps), Python-in substring and fuzzy-token + # comparisons are both expensive and low-value; doing them repeatedly + # made session loading block the whole WebUI for many seconds. Keep + # fuzzy matching for normal chat-sized text, but do exact-only matching + # for giant payloads. + if max(len(content), len(existing_content)) > 200_000: + continue if content in existing_content or existing_content in content: return existing_key if loose_content is None: loose_content = _loose_session_message_content(content) - loose_existing = lookup.get("loose_by_key", {}).get(existing_key, "") + loose_existing = loose_by_key.get(existing_key) + if loose_existing is None: + loose_existing = _loose_session_message_content(existing_content) + loose_by_key[existing_key] = loose_existing if loose_content and loose_existing and ( loose_content in loose_existing or loose_existing in loose_content ): diff --git a/api/routes.py b/api/routes.py index 7e54f04a217..80d81a0601b 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1088,6 +1088,7 @@ def _clear_live_models_cache() -> None: _get_session_agent_lock, SESSION_AGENT_LOCKS, SESSION_AGENT_LOCKS_LOCK, + CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS, load_settings, save_settings, set_hermes_default_model, @@ -1557,6 +1558,87 @@ def _client_ip_for_rate_limit(handler) -> str: return "unknown" +def _truthy_env(name: str) -> bool: + return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _request_client_ip(handler) -> str: + try: + address = getattr(handler, "client_address", None) + if address: + return str(address[0] or "") + except Exception: + pass + return "" + + +def _onboarding_request_is_local(handler) -> bool: + """Return True when an unauthenticated onboarding request is local/private. + + Forwarded client-IP headers are ignored by default because direct clients can + spoof them. Operators behind a trusted reverse proxy may opt in with + HERMES_WEBUI_TRUST_FORWARDED_FOR=1, matching the explicit forwarded-header + trust model used elsewhere in the server. + + When forwarded headers are PRESENT but not trusted, the request arrived + through a proxy, so the raw socket address is the proxy's (typically + loopback/private) and tells us nothing about the real client's locality. + In that case we deny rather than fall back to the proxy socket — otherwise a + public client behind any reverse proxy would be treated as local. Operators + who front the WebUI with a trusted proxy must set + HERMES_WEBUI_TRUST_FORWARDED_FOR=1 (or HERMES_WEBUI_ONBOARDING_OPEN=1). + """ + import ipaddress + + trust_forwarded = _truthy_env("HERMES_WEBUI_TRUST_FORWARDED_FOR") + if trust_forwarded: + candidates = [ + handler.headers.get("X-Forwarded-For", "").split(",")[-1].strip(), + handler.headers.get("X-Real-IP", "").strip(), + _request_client_ip(handler), + ] + for raw in candidates: + if not raw: + continue + try: + addr = ipaddress.ip_address(raw) + except ValueError: + continue + return bool(addr.is_loopback or addr.is_private) + return False + + # Untrusted forwarded headers present → the request arrived through a proxy. + # Ignore the spoofable header and judge by the raw socket, but only LOOPBACK + # counts as local in that case: a loopback raw socket is a genuine same-host + # client (or a same-host proxy the operator controls), whereas a PRIVATE/LAN + # raw socket is a separate proxy box that could be forwarding an arbitrary + # (public) client we can't see without trusting the header. Operators who + # front the WebUI with a LAN proxy must set HERMES_WEBUI_TRUST_FORWARDED_FOR=1 + # (or HERMES_WEBUI_ONBOARDING_OPEN=1). + forwarded_present = bool( + handler.headers.get("X-Forwarded-For", "").strip() + or handler.headers.get("X-Real-IP", "").strip() + ) + raw = _request_client_ip(handler) + if not raw: + return False + try: + addr = ipaddress.ip_address(raw) + except ValueError: + return False + if forwarded_present: + return bool(addr.is_loopback) + return bool(addr.is_loopback or addr.is_private) + + +def _onboarding_gate_allows(handler) -> bool: + from api.auth import is_auth_enabled + + if is_auth_enabled() or _truthy_env("HERMES_WEBUI_ONBOARDING_OPEN"): + return True + return _onboarding_request_is_local(handler) + + def _csp_report_rate_limited(handler, *, now: float | None = None) -> bool: now = time.time() if now is None else now key = _client_ip_for_rate_limit(handler) @@ -1893,6 +1975,223 @@ def _split(value: str) -> tuple[str, str | None]: return True +class _ContextLengthLookupInputs: + __slots__ = ("config_context_length", "custom_providers", "base_url", "provider") + + def __init__( + self, + *, + config_context_length: int | None = None, + custom_providers: list | None = None, + base_url: str = "", + provider: str = "", + ) -> None: + self.config_context_length = config_context_length + self.custom_providers = custom_providers + self.base_url = base_url + self.provider = provider + + +def _positive_context_length(value) -> int | None: + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def _model_lookup_candidates(model: str) -> tuple[str, ...]: + raw = str(model or "").strip() + candidates = [] + for candidate in (raw, _split_provider_qualified_model(raw)[0]): + if candidate and candidate not in candidates: + candidates.append(candidate) + if "/" in candidate: + bare = candidate.split("/", 1)[1].strip() + if bare and bare not in candidates: + candidates.append(bare) + return tuple(candidates) + + +def _models_config_context_length(models_cfg, model: str) -> int | None: + candidates = _model_lookup_candidates(model) + if isinstance(models_cfg, dict): + for candidate in candidates: + entry = models_cfg.get(candidate) + raw_ctx = entry.get("context_length") if isinstance(entry, dict) else entry + ctx = _positive_context_length(raw_ctx) + if ctx is not None: + return ctx + if isinstance(models_cfg, list): + for entry in models_cfg: + if not isinstance(entry, dict): + continue + entry_model = str(entry.get("id") or entry.get("model") or entry.get("name") or "").strip() + if entry_model in candidates: + ctx = _positive_context_length(entry.get("context_length")) + if ctx is not None: + return ctx + return None + + +def _canonical_context_provider(value: str | None) -> str: + provider = _clean_session_model_provider(value) or "" + if not provider: + return "" + try: + from api.config import _resolve_provider_alias + + provider = _resolve_provider_alias(provider) + except Exception: + pass + return str(provider or "").strip().lower() + + +def _custom_provider_slug_for_context(name: object) -> str: + try: + from api.config import _custom_provider_slug_from_name + + return _custom_provider_slug_from_name(name) + except Exception: + raw = str(name or "").strip().lower() + if not raw: + return "" + if raw.startswith("custom:"): + return raw + slug = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-") + slug = re.sub(r"-{2,}", "-", slug) + return f"custom:{slug}" if slug else "" + + +def _providers_match_for_context(config_key: object, requested_provider: str) -> bool: + if not requested_provider: + return False + raw_key = str(config_key or "").strip().lower() + key = _canonical_context_provider(raw_key) + requested = _canonical_context_provider(requested_provider) + return bool( + requested + and ( + raw_key == requested + or key == requested + or raw_key == str(requested_provider or "").strip().lower() + ) + ) + + +def _context_length_lookup_inputs_for_model( + model: str | None, + provider: str | None = None, + *, + base_url: str | None = None, + cfg: dict | None = None, +) -> _ContextLengthLookupInputs: + """Return the effective metadata resolver inputs for a WebUI model. + + ``agent.model_metadata.get_model_context_length`` understands global + ``config_context_length`` and custom-provider overrides, but only when the + matching base URL is supplied. WebUI also owns ``providers..models`` + overrides, so normalize those here and keep route/session-save/SSE aligned. + """ + model_for_lookup = str(model or "").strip() + if not model_for_lookup: + return _ContextLengthLookupInputs() + + if cfg is None: + try: + from api.config import get_config as _get_config_for_cl + + cfg = _get_config_for_cl() + except Exception: + cfg = {} + cfg = cfg if isinstance(cfg, dict) else {} + + bare_model, explicit_provider = _split_provider_qualified_model(model_for_lookup) + effective_provider = _canonical_context_provider(provider or explicit_provider) + effective_base_url = str(base_url or "").strip() + + model_cfg = cfg.get("model", {}) if isinstance(cfg, dict) else {} + if isinstance(model_cfg, dict): + if not effective_provider: + effective_provider = _canonical_context_provider(model_cfg.get("provider")) + if not effective_base_url: + effective_base_url = str(model_cfg.get("base_url") or "").strip() + + custom_providers = cfg.get("custom_providers") if isinstance(cfg, dict) else None + if not isinstance(custom_providers, list): + custom_providers = None + + provider_context_length = None + providers_cfg = cfg.get("providers", {}) if isinstance(cfg, dict) else {} + if isinstance(providers_cfg, dict): + for provider_key, provider_cfg in providers_cfg.items(): + if not isinstance(provider_cfg, dict): + continue + if not _providers_match_for_context(provider_key, effective_provider): + continue + if not effective_base_url: + effective_base_url = str(provider_cfg.get("base_url") or "").strip() + provider_context_length = _models_config_context_length( + provider_cfg.get("models"), + bare_model or model_for_lookup, + ) + break + + custom_context_length = None + if custom_providers: + target_base = effective_base_url.rstrip("/") + model_candidates = set(_model_lookup_candidates(bare_model or model_for_lookup)) + for entry in custom_providers: + if not isinstance(entry, dict): + continue + entry_name = str(entry.get("name") or "").strip() + entry_slug = _custom_provider_slug_for_context(entry_name) + entry_base = str(entry.get("base_url") or "").strip() + entry_base_norm = entry_base.rstrip("/") + provider_matches = bool( + effective_provider + and ( + effective_provider == entry_slug + or effective_provider == entry_name.lower() + or (effective_provider == "custom" and len(custom_providers) == 1) + ) + ) + base_matches = bool(target_base and entry_base_norm and target_base == entry_base_norm) + model_matches = bool(model_candidates.intersection(set(_model_lookup_candidates(entry.get("model"))))) + models_cfg = entry.get("models") + if isinstance(models_cfg, dict): + model_matches = model_matches or any(candidate in models_cfg for candidate in model_candidates) + if not (provider_matches or base_matches or (not effective_provider and model_matches)): + continue + if not effective_provider and entry_slug: + effective_provider = entry_slug + if not effective_base_url and entry_base: + effective_base_url = entry_base + custom_context_length = _models_config_context_length(models_cfg, bare_model or model_for_lookup) + break + + global_context_length = None + if isinstance(model_cfg, dict): + cfg_default_model = str(model_cfg.get("default") or "").strip() + raw_cfg_ctx = model_cfg.get("context_length") + if raw_cfg_ctx is not None and ( + not cfg_default_model + or _model_matches_configured_default( + model_for_lookup, + cfg_default_model, + effective_provider, + ) + ): + global_context_length = _positive_context_length(raw_cfg_ctx) + + return _ContextLengthLookupInputs( + config_context_length=provider_context_length or custom_context_length or global_context_length, + custom_providers=custom_providers, + base_url=effective_base_url, + provider=effective_provider, + ) + + def _should_attach_codex_provider_context(model: str, raw_active_provider: str, catalog: dict) -> bool: """Return True when a bare Codex model needs separate provider context. @@ -2282,43 +2581,22 @@ def _resolve_context_length_for_session_model( from api.config import get_config as _get_config_for_cl _cfg_for_cl = _get_config_for_cl() - _cfg_ctx_len_load = None - _cfg_custom_providers_load = None - try: - _model_cfg_load = _cfg_for_cl.get('model', {}) if isinstance(_cfg_for_cl, dict) else {} - if isinstance(_model_cfg_load, dict): - # Only apply the global model.context_length override when the - # session model matches model.default. Otherwise a global cap - # set for the default model (e.g. 232000) silently clobbers - # other models' real metadata (e.g. a 1M-context variant). - _cfg_default_model = str(_model_cfg_load.get('default') or '').strip() - _raw_cfg_ctx_load = _model_cfg_load.get('context_length') - if _raw_cfg_ctx_load is not None and ( - not _cfg_default_model - or _model_matches_configured_default(model_for_lookup, _cfg_default_model, provider) - ): - try: - _parsed_load = int(_raw_cfg_ctx_load) - if _parsed_load > 0: - _cfg_ctx_len_load = _parsed_load - except (TypeError, ValueError): - pass - _raw_cp_load = _cfg_for_cl.get('custom_providers') if isinstance(_cfg_for_cl, dict) else None - if isinstance(_raw_cp_load, list): - _cfg_custom_providers_load = _raw_cp_load - except Exception: - pass + _ctx_lookup = _context_length_lookup_inputs_for_model( + model_for_lookup, + provider, + cfg=_cfg_for_cl if isinstance(_cfg_for_cl, dict) else {}, + ) try: return _get_cl( model_for_lookup, - "", - config_context_length=_cfg_ctx_len_load, - provider=provider or "", - custom_providers=_cfg_custom_providers_load, + _ctx_lookup.base_url, + config_context_length=_ctx_lookup.config_context_length, + provider=_ctx_lookup.provider or provider or "", + custom_providers=_ctx_lookup.custom_providers, ) or 0 except TypeError: # Older hermes-agent builds: legacy 2-arg form. - return _get_cl(model_for_lookup, "") or 0 + return _get_cl(model_for_lookup, _ctx_lookup.base_url) or 0 except Exception: return 0 @@ -3027,6 +3305,7 @@ def _keep_latest_messaging_session_per_source( validate_workspace_to_add, _is_blocked_system_path, _strip_surrounding_quotes, + _is_remote_terminal_backend, _workspace_blocked_roots, ) from api.upload import handle_upload, handle_upload_extract, handle_transcribe, handle_workspace_upload @@ -5919,7 +6198,12 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/workspaces": return j( - handler, {"workspaces": load_workspaces(), "last": get_last_workspace()} + handler, + { + "workspaces": load_workspaces(), + "last": get_last_workspace(), + "terminal_remote_backend": _terminal_remote_backend_enabled(), + }, ) if parsed.path == "/api/workspaces/suggest": @@ -6007,7 +6291,6 @@ def handle_get(handler, parsed) -> bool: return j(handler, {"disabled": True}) include_agent_updates = not bool(settings.get("ignore_agent_updates")) qs = parse_qs(parsed.query) - force = qs.get("force", ["0"])[0] == "1" # ?simulate=1 returns fake behind counts for UI testing (localhost only) if ( qs.get("simulate", ["0"])[0] == "1" @@ -6038,9 +6321,9 @@ def handle_get(handler, parsed) -> bool: "checked_at": 0, }, ) - from api.updates import check_for_updates + from api.updates import cached_update_status - return j(handler, check_for_updates(force=force, include_agent=include_agent_updates)) + return j(handler, cached_update_status(include_agent=include_agent_updates)) if parsed.path == "/api/chat/stream/status": stream_id = parse_qs(parsed.query).get("stream_id", [""])[0] @@ -6622,6 +6905,16 @@ def handle_post(handler, parsed) -> bool: diag.finish() raise + if parsed.path == "/api/updates/check": + settings = load_settings() + if not settings.get("check_for_updates", True): + return j(handler, {"disabled": True}) + include_agent_updates = not bool(settings.get("ignore_agent_updates")) + force = bool(body.get("force", False)) + from api.updates import check_for_updates + + return j(handler, check_for_updates(force=force, include_agent=include_agent_updates)) + if parsed.path == "/api/session/recovery/repair-safe": from api.session_recovery import repair_safe_session_recovery result = repair_safe_session_recovery(SESSION_DIR, state_db_path=_active_state_db_path()) @@ -7919,20 +8212,8 @@ def _sync_session_title_to_insights(session): return True if parsed.path == "/api/onboarding/oauth/start": - from api.auth import is_auth_enabled - import os as _os - if not is_auth_enabled() and not _os.getenv("HERMES_WEBUI_ONBOARDING_OPEN"): - import ipaddress - try: - _xff = handler.headers.get("X-Forwarded-For", "").split(",")[0].strip() - _xri = handler.headers.get("X-Real-IP", "").strip() - _raw = handler.client_address[0] - addr = ipaddress.ip_address(_xff or _xri or _raw) - is_local = addr.is_loopback or addr.is_private - except ValueError: - is_local = False - if not is_local: - return bad(handler, "Onboarding OAuth is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403) + if not _onboarding_gate_allows(handler): + return bad(handler, "Onboarding OAuth is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403) try: return j(handler, start_onboarding_oauth_flow(body), extra_headers={"Cache-Control": "no-store"}) except ValueError as e: @@ -7954,22 +8235,8 @@ def _sync_session_title_to_insights(session): # carries the real origin IP — read it first before falling back to the raw socket addr. # HERMES_WEBUI_ONBOARDING_OPEN=1 lets operators on remote servers explicitly bypass # the check when they control network access themselves (e.g. firewall + VPN). - from api.auth import is_auth_enabled - import os as _os - if not is_auth_enabled() and not _os.getenv("HERMES_WEBUI_ONBOARDING_OPEN"): - import ipaddress - try: - # Prefer forwarded headers set by reverse proxies - _xff = handler.headers.get("X-Forwarded-For", "").split(",")[0].strip() - _xri = handler.headers.get("X-Real-IP", "").strip() - _raw = handler.client_address[0] - _ip_str = _xff or _xri or _raw - addr = ipaddress.ip_address(_ip_str) - is_local = addr.is_loopback or addr.is_private - except ValueError: - is_local = False - if not is_local: - return bad(handler, "Onboarding setup is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403) + if not _onboarding_gate_allows(handler): + return bad(handler, "Onboarding setup is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403) try: return j(handler, apply_onboarding_setup(body)) except ValueError as e: @@ -7978,6 +8245,12 @@ def _sync_session_title_to_insights(session): return bad(handler, str(e), 500) if parsed.path == "/api/onboarding/complete": + # Marking onboarding complete flips the first-run wizard off (persists + # onboarding_completed=True). Gate it on the same local-network check as + # the other onboarding mutators so an unauthenticated public client on a + # passwordless bind can't hide the first-run wizard. (#3765) + if not _onboarding_gate_allows(handler): + return bad(handler, "Onboarding is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403) return j(handler, complete_onboarding()) if parsed.path == "/api/onboarding/probe": @@ -7987,21 +8260,8 @@ def _sync_session_title_to_insights(session): # Read-only: no config.yaml or .env writes happen here. Same local- # network gate as /api/onboarding/setup (also writing-adjacent in # spirit because it carries an api_key the user typed). - from api.auth import is_auth_enabled - import os as _os - if not is_auth_enabled() and not _os.getenv("HERMES_WEBUI_ONBOARDING_OPEN"): - import ipaddress - try: - _xff = handler.headers.get("X-Forwarded-For", "").split(",")[0].strip() - _xri = handler.headers.get("X-Real-IP", "").strip() - _raw = handler.client_address[0] - _ip_str = _xff or _xri or _raw - addr = ipaddress.ip_address(_ip_str) - is_local = addr.is_loopback or addr.is_private - except ValueError: - is_local = False - if not is_local: - return bad(handler, "Onboarding probe is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403) + if not _onboarding_gate_allows(handler): + return bad(handler, "Onboarding probe is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403) provider = str((body or {}).get("provider") or "").strip().lower() base_url = str((body or {}).get("base_url") or "") api_key = str((body or {}).get("api_key") or "").strip() or None @@ -9163,7 +9423,7 @@ def _handle_sse_stream(handler, parsed): return True -def _terminal_session_and_workspace(body_or_query): +def _terminal_session_lookup(body_or_query): sid = str(body_or_query.get("session_id", "")).strip() if not sid: raise ValueError("session_id required") @@ -9171,13 +9431,33 @@ def _terminal_session_and_workspace(body_or_query): s = get_session(sid) except KeyError: raise KeyError("Session not found") - workspace = resolve_trusted_workspace(getattr(s, "workspace", "") or "") - return sid, workspace + return sid, s + + +_REMOTE_TERMINAL_BACKEND_UNSUPPORTED_ERROR = "remote_terminal_backend_unsupported" +_REMOTE_TERMINAL_BACKEND_UNSUPPORTED_MESSAGE = ( + "Embedded terminal is only supported for local terminal backends." +) + + +def _terminal_remote_backend_enabled() -> bool: + terminal_cfg = get_config().get("terminal", {}) + return _is_remote_terminal_backend(terminal_cfg) def _handle_terminal_start(handler, body): try: - sid, workspace = _terminal_session_and_workspace(body) + sid, session = _terminal_session_lookup(body) + if _terminal_remote_backend_enabled(): + return j( + handler, + { + "error": _REMOTE_TERMINAL_BACKEND_UNSUPPORTED_ERROR, + "message": _REMOTE_TERMINAL_BACKEND_UNSUPPORTED_MESSAGE, + }, + status=400, + ) + workspace = resolve_trusted_workspace(getattr(session, "workspace", "") or "") from api.terminal import start_terminal term = start_terminal( sid, @@ -10695,14 +10975,19 @@ def _custom_provider_api_key(_cp): # Fall back to the custom_providers entries from config.yaml so # the live-model enrichment step can add any models that weren't # already in the static list (issue #1619). + # Collect config-specified model IDs separately so they don't + # prevent the live fetch below from running (#3718). + _config_ids = [] if provider == "custom" or provider.startswith("custom:"): for _cp in _custom_provider_entries_for_request(): if custom_provider_entry is None: custom_provider_entry = _cp - ids.extend(_custom_provider_model_ids(_cp)) + _config_ids.extend(_custom_provider_model_ids(_cp)) - # If still no ids, try fetching from base_url directly (OpenAI-compat endpoint) - if not ids and (provider == "custom" or provider.startswith("custom:")): + # Always try live fetch for custom providers — config entries are a + # fallback, not a replacement. The live endpoint should return ALL + # models the key has access to, not just what's listed in config.yaml. + if provider == "custom" or provider.startswith("custom:"): _base_url = None _api_key = None if custom_provider_entry: @@ -10731,7 +11016,7 @@ def _custom_provider_api_key(_cp): headers={"Authorization": f"Bearer {_api_key}"}, ) - with urllib.request.urlopen(_req, timeout=8) as _resp: + with urllib.request.urlopen(_req, timeout=CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS) as _resp: _body = json.loads(_resp.read()) # Parse response: {"data": [{"id": "model1", ...}, ...]} @@ -10749,6 +11034,16 @@ def _custom_provider_api_key(_cp): except Exception as _fetch_err: logger.debug("Live fetch from custom provider failed: %s", _fetch_err) + + # If live fetch succeeded, merge with config entries (live takes + # priority). If live fetch failed, fall back to config-only list. + if ids: + _live_set = set(ids) + for _cid in _config_ids: + if _cid not in _live_set: + ids.append(_cid) + else: + ids = list(_config_ids) # ── OpenAI-compat live fetch fallback ────────────────────────────────── # When provider_model_ids() is unavailable or returns [] for a provider diff --git a/api/streaming.py b/api/streaming.py index 9f53d044d2c..08ad0eb5694 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -6711,44 +6711,23 @@ def _periodic_checkpoint(): if (not getattr(s, 'context_length', 0)) or _skip_cc_cl: try: from agent.model_metadata import get_model_context_length - _cfg_ctx_len = None - _cfg_custom_providers = None - try: - _model_cfg_for_ctx = _cfg.get('model', {}) if isinstance(_cfg, dict) else {} - if isinstance(_model_cfg_for_ctx, dict): - _raw_cfg_ctx = _model_cfg_for_ctx.get('context_length') - # Default-only guard: only apply the global - # model.context_length cap when the session - # model equals model.default. Otherwise the - # cap (e.g. 232K set for the default model) - # silently shrinks other models' real metadata. - _cfg_default_ctx = str(_model_cfg_for_ctx.get('default') or '').strip() - _sess_model_ctx = str(getattr(agent, 'model', resolved_model or '') or '').strip() - from api.routes import _model_matches_configured_default as _mmcd_ctx - _apply_cfg_ctx = ( - not _cfg_default_ctx - or not _sess_model_ctx - or _mmcd_ctx(_sess_model_ctx, _cfg_default_ctx, resolved_provider or '') - ) - if _raw_cfg_ctx is not None and _apply_cfg_ctx: - try: - _parsed_cfg_ctx = int(_raw_cfg_ctx) - if _parsed_cfg_ctx > 0: - _cfg_ctx_len = _parsed_cfg_ctx - except (TypeError, ValueError): - # Invalid config — let the resolver fall - # through to provider/registry probing. - pass - _raw_cp = _cfg.get('custom_providers') if isinstance(_cfg, dict) else None - if isinstance(_raw_cp, list): - _cfg_custom_providers = _raw_cp - except Exception: - pass + from api.routes import _context_length_lookup_inputs_for_model + _cfg_base_url = getattr(agent, 'base_url', '') or resolved_base_url or '' + _ctx_lookup = _context_length_lookup_inputs_for_model( + getattr(agent, 'model', resolved_model or '') or '', + resolved_provider, + base_url=_cfg_base_url, + cfg=_cfg if isinstance(_cfg, dict) else {}, + ) + _cfg_ctx_len = _ctx_lookup.config_context_length + _cfg_custom_providers = _ctx_lookup.custom_providers + _cfg_base_url = _ctx_lookup.base_url or _cfg_base_url + _cfg_provider = _ctx_lookup.provider or resolved_provider or '' _resolved_cl = get_model_context_length( getattr(agent, 'model', resolved_model or '') or '', - getattr(agent, 'base_url', '') or '', + _cfg_base_url, config_context_length=_cfg_ctx_len, - provider=resolved_provider or '', + provider=_cfg_provider, custom_providers=_cfg_custom_providers, ) if _resolved_cl: @@ -6762,7 +6741,7 @@ def _periodic_checkpoint(): from agent.model_metadata import get_model_context_length as _legacy_cl _resolved_cl = _legacy_cl( getattr(agent, 'model', resolved_model or '') or '', - getattr(agent, 'base_url', '') or '', + _cfg_base_url, ) if _resolved_cl: s.context_length = _resolved_cl @@ -6991,49 +6970,30 @@ def _periodic_checkpoint(): if not usage.get('context_length'): try: from agent.model_metadata import get_model_context_length as _get_cl - _cfg_ctx_len = None - _cfg_custom_providers = None - try: - _model_cfg_for_ctx = _cfg.get('model', {}) if isinstance(_cfg, dict) else {} - if isinstance(_model_cfg_for_ctx, dict): - _raw_cfg_ctx = _model_cfg_for_ctx.get('context_length') - # Default-only guard (see #3256): the global - # model.context_length cap only applies to - # model.default; other models keep their real - # metadata. - _cfg_default_ctx = str(_model_cfg_for_ctx.get('default') or '').strip() - _sess_model_ctx = str(getattr(agent, 'model', resolved_model or '') or '').strip() - from api.routes import _model_matches_configured_default as _mmcd_sfb - _apply_cfg_ctx = ( - not _cfg_default_ctx - or not _sess_model_ctx - or _mmcd_sfb(_sess_model_ctx, _cfg_default_ctx, resolved_provider or '') - ) - if _raw_cfg_ctx is not None and _apply_cfg_ctx: - try: - _parsed_cfg_ctx = int(_raw_cfg_ctx) - if _parsed_cfg_ctx > 0: - _cfg_ctx_len = _parsed_cfg_ctx - except (TypeError, ValueError): - pass - _raw_cp = _cfg.get('custom_providers') if isinstance(_cfg, dict) else None - if isinstance(_raw_cp, list): - _cfg_custom_providers = _raw_cp - except Exception: - pass + from api.routes import _context_length_lookup_inputs_for_model + _ctx_lookup = _context_length_lookup_inputs_for_model( + getattr(agent, 'model', resolved_model or '') or '', + resolved_provider, + base_url=getattr(agent, 'base_url', '') or resolved_base_url or '', + cfg=_cfg if isinstance(_cfg, dict) else {}, + ) + _cfg_ctx_len = _ctx_lookup.config_context_length + _cfg_custom_providers = _ctx_lookup.custom_providers + _cfg_base_url = _ctx_lookup.base_url + _cfg_provider = _ctx_lookup.provider or resolved_provider or '' try: _fb_cl = _get_cl( getattr(agent, 'model', resolved_model or '') or '', - getattr(agent, 'base_url', '') or '', + _cfg_base_url, config_context_length=_cfg_ctx_len, - provider=resolved_provider or '', + provider=_cfg_provider, custom_providers=_cfg_custom_providers, ) except TypeError: # Older hermes-agent builds: fall back to legacy 2-arg form. _fb_cl = _get_cl( getattr(agent, 'model', resolved_model or '') or '', - getattr(agent, 'base_url', '') or '', + _cfg_base_url, ) if _fb_cl: usage['context_length'] = _fb_cl diff --git a/api/terminal.py b/api/terminal.py index c85e1f3ec03..18c1fedbe79 100644 --- a/api/terminal.py +++ b/api/terminal.py @@ -89,6 +89,8 @@ def put_output(self, event: str, payload: dict) -> None: _spawn_supervisor_started = False _spawn_supervisor_lock = threading.Lock() _spawn_supervisor_thread: threading.Thread | None = None +_terminal_descendant_reaper_lock = threading.Lock() +_TERMINAL_DESCENDANT_REAPER_LIMIT = 64 @dataclass @@ -131,6 +133,32 @@ def _reap_abandoned_spawn(proc: subprocess.Popen) -> bool: return True +def _reap_terminal_descendants( + terminal_pgid: int, + limit: int = _TERMINAL_DESCENDANT_REAPER_LIMIT, +) -> int: + """Reap exited descendants that still belong to a terminal-owned process group.""" + if not _TERMINAL_SUPPORTED: + return 0 + try: + terminal_pgid = abs(int(terminal_pgid)) + except (TypeError, ValueError): + return 0 + if terminal_pgid <= 0: + return 0 + reaped = 0 + with _terminal_descendant_reaper_lock: + for _ in range(max(0, int(limit))): + try: + pid, _status = os.waitpid(-terminal_pgid, os.WNOHANG) + except (ChildProcessError, OSError): + break + if pid == 0: + break + reaped += 1 + return reaped + + def _spawn_supervisor_loop() -> None: while True: request = None @@ -248,6 +276,7 @@ def _reader_loop(term: TerminalSession) -> None: finally: term.closed.set() code = term.proc.poll() + _reap_terminal_descendants(term.proc.pid) term.put_output("terminal_closed", {"exit_code": code}) @@ -416,6 +445,7 @@ def close_terminal(session_id: str) -> bool: os.close(term.master_fd) except OSError: pass + _reap_terminal_descendants(term.proc.pid) return True diff --git a/api/updates.py b/api/updates.py index bb4dbbf07f4..0ae0ce82212 100644 --- a/api/updates.py +++ b/api/updates.py @@ -811,6 +811,19 @@ def _ignored_agent_update_info() -> dict: return {'name': 'agent', 'behind': 0, 'ignored': True} +def cached_update_status(*, include_agent=True): + """Return cached update status without performing network or git mutations.""" + include_agent = bool(include_agent) + with _cache_lock: + cached = dict(_update_cache) + if cached.get('include_agent') != include_agent: + cached['include_agent'] = include_agent + if not include_agent: + cached['agent'] = _ignored_agent_update_info() + cached['cached'] = True + return cached + + def check_for_updates(force=False, *, include_agent=True): """Return cached update status for webui and agent repos.""" global _check_in_progress diff --git a/api/webui_session_db.py b/api/webui_session_db.py new file mode 100644 index 00000000000..1d3a119037b --- /dev/null +++ b/api/webui_session_db.py @@ -0,0 +1,250 @@ +"""Dormant JSON-backed SessionDB-shaped adapter for WebUI sessions. + +This module intentionally does not replace existing WebUI runtime call sites. +It provides a small compatibility surface over the current JSON sidecars so the +unified SessionDB contract can be tested without changing persistence behavior. +""" + +from __future__ import annotations + +import copy +import json +import os +import threading +from pathlib import Path +from typing import Any + +import api.models as models + + +_METADATA_FIELDS = frozenset( + { + "title", + "workspace", + "model", + "model_provider", + "created_at", + "updated_at", + "pinned", + "archived", + "project_id", + "profile", + "input_tokens", + "output_tokens", + "estimated_cost", + "cache_read_tokens", + "cache_write_tokens", + "personality", + "active_stream_id", + "pending_user_message", + "pending_attachments", + "pending_started_at", + "compression_anchor_visible_idx", + "compression_anchor_message_key", + "compression_anchor_summary", + "pre_compression_snapshot", + "context_engine", + "compression_anchor_engine", + "compression_anchor_mode", + "compression_anchor_details", + "context_engine_state", + "context_length", + "threshold_tokens", + "last_prompt_tokens", + "truncation_watermark", + "gateway_routing", + "gateway_routing_history", + "llm_title_generated", + "manual_title", + "parent_session_id", + "worktree_path", + "worktree_branch", + "worktree_repo_root", + "worktree_created_at", + "is_cli_session", + "source_tag", + "raw_source", + "session_source", + "source_label", + "read_only", + "enabled_toolsets", + "composer_draft", + } +) + +_UNSAFE_FIELDS = frozenset({"session_id", "messages", "tool_calls", "message_count"}) + + +class WebUIJsonSessionDB: + """Small SessionDB-like facade over existing WebUI session JSON files.""" + + def __init__(self, session_dir: Path | str | None = None): + self._session_dir = Path(session_dir).expanduser().resolve() if session_dir else None + + @property + def session_dir(self) -> Path: + return self._session_dir or models.SESSION_DIR + + def list_sessions(self) -> list[dict[str, Any]]: + """Return compact metadata for persisted WebUI JSON sessions. + + Reads are direct JSON loads and never call ``Session.load()``, because + that path may self-heal and write repaired transcripts. + """ + rows: list[dict[str, Any]] = [] + if not self.session_dir.exists(): + return rows + for path in self.session_dir.glob("*.json"): + if path.name.startswith("_"): + continue + data = self._read_path(path) + if not isinstance(data, dict): + continue + sid = str(data.get("session_id") or path.stem) + if not models.is_safe_session_id(sid): + continue + rows.append(self._metadata_row(sid, data)) + rows.sort(key=lambda row: (bool(row.get("pinned")), self._sort_timestamp(row)), reverse=True) + return rows + + def read_session(self, sid: str) -> dict[str, Any] | None: + """Return the full JSON session payload for ``sid`` without mutation.""" + path = self._path_for_sid(sid) + if path is None or not path.exists(): + return None + data = self._read_path(path) + if not isinstance(data, dict): + return None + return copy.deepcopy(data) + + def update_metadata(self, sid: str, fields: dict[str, Any]) -> dict[str, Any]: + """Persist allowlisted metadata fields while preserving messages. + + This dormant adapter method is for migration experiments and tests only. + Runtime wiring must add Session lock/cache/index parity before using it + from live WebUI routes. + """ + if not isinstance(fields, dict): + raise TypeError("fields must be a dict") + unsafe = sorted((set(fields) & _UNSAFE_FIELDS) | (set(fields) - _METADATA_FIELDS)) + if unsafe: + raise ValueError(f"Unsafe session metadata fields: {', '.join(unsafe)}") + + path = self._existing_path_for_sid(sid) + data = self._read_writable_session(path) + data.update(copy.deepcopy(fields)) + data["message_count"] = len(data["messages"]) + self._atomic_write(path, data) + return self._metadata_row(str(data.get("session_id") or sid), data) + + def archive(self, sid: str, archived: bool = True) -> dict[str, Any]: + """Set the archived metadata flag without touching transcript messages.""" + return self.update_metadata(sid, {"archived": bool(archived)}) + + def write_session(self, session: dict[str, Any]) -> dict[str, Any]: + """Write a full session payload for tests and migration experiments.""" + if not isinstance(session, dict): + raise TypeError("session must be a dict") + sid = session.get("session_id") + path = self._path_for_sid(sid) + if path is None: + raise ValueError(f"Unsafe session_id {sid!r}") + messages = session.get("messages") + if not isinstance(messages, list): + raise ValueError("session payload must include a messages list") + payload = copy.deepcopy(session) + payload["message_count"] = len(messages) + path.parent.mkdir(parents=True, exist_ok=True) + self._atomic_write(path, payload) + return copy.deepcopy(payload) + + def _path_for_sid(self, sid: str) -> Path | None: + if not models.is_safe_session_id(sid): + return None + return self.session_dir / f"{sid}.json" + + def _existing_path_for_sid(self, sid: str) -> Path: + path = self._path_for_sid(sid) + if path is None: + raise ValueError(f"Unsafe session_id {sid!r}") + if not path.exists(): + raise KeyError(sid) + return path + + def _read_writable_session(self, path: Path) -> dict[str, Any]: + data = self._read_path(path) + if not isinstance(data, dict): + raise ValueError(f"Malformed session JSON: {path.name}") + sid = data.get("session_id") + if not models.is_safe_session_id(sid): + raise ValueError(f"Unsafe session_id {sid!r}") + if not isinstance(data.get("messages"), list): + raise ValueError(f"Refusing to write metadata-only session stub: {sid!r}") + return data + + @staticmethod + def _read_path(path: Path) -> dict[str, Any] | None: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return None + + @staticmethod + def _metadata_row(sid: str, data: dict[str, Any]) -> dict[str, Any]: + messages = data.get("messages") + message_count = data.get("message_count") + if not isinstance(message_count, int): + message_count = len(messages) if isinstance(messages, list) else 0 + row = {field: copy.deepcopy(data.get(field)) for field in _METADATA_FIELDS if field in data} + row["session_id"] = sid + row["message_count"] = message_count + row["last_message_at"] = data.get("last_message_at") or data.get("updated_at") or data.get("created_at") + return row + + @staticmethod + def _sort_timestamp(row: dict[str, Any]) -> float: + for key in ("last_message_at", "updated_at", "created_at"): + value = row.get(key) + if value is None or value == "": + continue + try: + return float(value) + except (TypeError, ValueError): + continue + return 0.0 + + @staticmethod + def _atomic_write(path: Path, data: dict[str, Any]) -> None: + payload = json.dumps(data, ensure_ascii=False, indent=2) + tmp = path.with_suffix(f".tmp.{os.getpid()}.{threading.current_thread().ident}") + try: + with open(tmp, "w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + finally: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + + +def list_sessions() -> list[dict[str, Any]]: + return WebUIJsonSessionDB().list_sessions() + + +def read_session(sid: str) -> dict[str, Any] | None: + return WebUIJsonSessionDB().read_session(sid) + + +def update_metadata(sid: str, fields: dict[str, Any]) -> dict[str, Any]: + return WebUIJsonSessionDB().update_metadata(sid, fields) + + +def archive(sid: str, archived: bool = True) -> dict[str, Any]: + return WebUIJsonSessionDB().archive(sid, archived) + + +def write_session(session: dict[str, Any]) -> dict[str, Any]: + return WebUIJsonSessionDB().write_session(session) diff --git a/api/workspace.py b/api/workspace.py index e4b7857fd7b..a1c9b44e8bc 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -93,6 +93,8 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None: return None candidate = Path(raw).expanduser().resolve() base = Path(cwd).expanduser().resolve() + if _is_blocked_workspace_path(candidate, raw) or _is_blocked_workspace_path(base, cwd): + return None if candidate == base or _is_within(candidate, base): return candidate return None diff --git a/api/workspace_git.py b/api/workspace_git.py index 23ac79c9309..4ddadff2acd 100644 --- a/api/workspace_git.py +++ b/api/workspace_git.py @@ -9,7 +9,6 @@ import difflib import os -import shutil import subprocess import tempfile import threading @@ -18,7 +17,7 @@ from pathlib import Path from typing import Iterable -from api.workspace import safe_resolve_ws +from api.workspace import rmtree_anchored, safe_resolve_ws, unlink_anchored GIT_TIMEOUT = 5 @@ -978,9 +977,16 @@ def git_discard(workspace: str | Path, paths: Iterable[str], *, delete_untracked raise GitWorkspaceError("Untracked files require delete_untracked=true") target = safe_resolve_ws(ctx.workspace, workspace_rel) if target.is_dir(): - shutil.rmtree(target) + rmtree_anchored(ctx.workspace, target) else: - target.unlink(missing_ok=True) + try: + unlink_anchored(ctx.workspace, target) + except FileNotFoundError: + # Preserve the previous Path.unlink(missing_ok=True) + # behavior for benign races where another process + # removes the untracked file after git_status() has + # reported it but before this discard reaches unlink. + pass continue _run_git(ctx, ["restore", "--worktree", "--", repo_rel], check=True) return git_status(workspace) diff --git a/docker_init.bash b/docker_init.bash index 2002de15959..4d0003527cb 100644 --- a/docker_init.bash +++ b/docker_init.bash @@ -19,7 +19,7 @@ ok_exit() { # Ignore list: variables to ignore when loading environment variables from user to user export ENV_IGNORELIST="HOME PWD USER SHLVL TERM OLDPWD SHELL _ SUDO_COMMAND HOSTNAME LOGNAME MAIL SUDO_GID SUDO_UID SUDO_USER CHECK_NV_CUDNN_VERSION VIRTUAL_ENV VIRTUAL_ENV_PROMPT ENV_IGNORELIST ENV_OBFUSCATE_PART" # Obfuscate part: part of the key to obfuscate when loading environment variables from user to user, ex: HF_TOKEN, ... -export ENV_OBFUSCATE_PART="TOKEN API KEY" +export ENV_OBFUSCATE_PART="TOKEN API KEY PASSWORD SECRET CREDENTIAL COOKIE SESSION" # Check for ENV_IGNORELIST and ENV_OBFUSCATE_PART if [ -z "${ENV_IGNORELIST+x}" ]; then error_exit "ENV_IGNORELIST not set"; fi @@ -264,6 +264,29 @@ if [ "A${whoami}" == "Aroot" ]; then chmod 600 "$ENV_FILE" || error_exit "Failed to secure $ENV_FILE" export _HW_ROOT_ENV_PATH="$ENV_FILE" + # Preserve Docker --group-add supplemental groups (for example render/video + # for /dev/dri GPU access) when dropping privileges. `su` rebuilds the target + # user's groups from /etc/group, so host-passed numeric groups must be made + # visible to hermeswebui before re-entering as the runtime user. + for gid in $(id -G); do + if [ "$gid" = "0" ] || [ "$gid" = "$WANTED_GID" ]; then + continue + fi + group_name="$(getent group "$gid" | cut -d: -f1 || true)" + if [ -z "$group_name" ]; then + group_name="hostgpu${gid}" + groupadd -g "$gid" "$group_name" 2>/dev/null || true + group_name="$(getent group "$gid" | cut -d: -f1 || true)" + fi + if [ -z "$group_name" ]; then + echo "!! WARNING: Could not create supplemental group for GID $gid; GPU device access may be unavailable" + continue + fi + if [ -n "$group_name" ]; then + usermod -a -G "$group_name" hermeswebui 2>/dev/null || echo "!! WARNING: Could not add hermeswebui to supplemental group $group_name ($gid)" + fi + done + # restart the script as hermeswebui set with the correct UID/GID this time echo "-- Restarting as hermeswebui user with UID ${WANTED_UID} GID ${WANTED_GID}" exec su -s /bin/bash -c "exec \"${script_fullname}\"" hermeswebui || error_exit "subscript failed" diff --git a/docs/architecture/unified-session-db.md b/docs/architecture/unified-session-db.md new file mode 100644 index 00000000000..76489daaf3f --- /dev/null +++ b/docs/architecture/unified-session-db.md @@ -0,0 +1,98 @@ +# Unified SessionDB Adapter Spike + +WebUI currently persists conversations as JSON files under the WebUI session +directory, while the CLI uses its own session database. The first safe slice of +unification is a dormant adapter that presents a small SessionDB-shaped API over +the existing WebUI JSON files without changing runtime call sites or file +format. + +## Adapter Contract + +`api.webui_session_db.WebUIJsonSessionDB` exposes: + +- `list_sessions()` returns compact metadata rows for persisted WebUI JSON + sessions. +- `read_session(sid)` returns a full session JSON payload or `None`. +- `update_metadata(sid, fields)` writes only allowlisted metadata fields and + rejects unsafe keys such as `session_id`, `messages`, `tool_calls`, and + `message_count`. +- `archive(sid, archived=True)` is a convenience metadata update for the + archived flag. +- `write_session(session)` exists for tests and migration experiments that need + to materialize a complete JSON payload. + +Read operations must not call `Session.load()` or `all_sessions()`, because +those paths can repair indexes or transcripts. Metadata writes must load the +complete JSON payload, verify that a real `messages` list is present, update only +safe fields, recompute `message_count`, and atomically replace the file. The +adapter must never write a metadata-only stub that could drop transcript +messages. + +## Why JSON-Backed And Dormant + +The selected first slice is infrastructure only. Keeping the adapter backed by +the current JSON sidecars validates the API shape while preserving all current +WebUI behavior, backups, and import paths. The feature flag defaults to: + +```yaml +experimental: + unified_session_db: false +``` + +No UI exposes this flag, and no runtime session route switches to the adapter in +this slice. + +## Runtime Wiring Preconditions + +Before any route uses this adapter for live metadata changes, a follow-up PR must +prove parity with the existing `Session.save()` path: + +- take the same per-session mutation locks used by streaming and session routes, + so metadata writes cannot replace a newer transcript with a stale copy; +- refresh or invalidate the in-memory `Session` cache and `_index.json`, so + sidebar rows and later `Session.save()` calls cannot overwrite adapter changes; +- match `Session.compact()` sidebar semantics for pending first turns, + `has_pending_user_message`, `pending_started_at`, and real non-tool + `last_message_at` ordering. + +Until those invariants are implemented, `update_metadata()` and `archive()` are +test/migration helpers, not runtime persistence replacements. + +## Planned Migration Sequence + +1. Land the dormant JSON adapter and contract tests. +2. Add parity tests that compare adapter reads with existing WebUI sidebar and + session payloads. +3. Introduce an opt-in dual-read or shadow-read mode for development builds. +4. Add a migration path that can write unified SessionDB records without + deleting or rewriting JSON sidecars. +5. Switch selected call sites behind the flag only after parity and rollback + behavior are proven. +6. Make the unified store authoritative in a later release after import, + archive, pin, profile, project, and recovery semantics match WebUI JSON. + +## Authoritative Fields And Open Questions + +The JSON sidecar remains authoritative for `messages`, `tool_calls`, metadata +display fields, profile/project ownership, archive and pin state, token/cost +totals, pending stream recovery fields, worktree metadata, and composer draft +state during this spike. + +Open questions for later slices: + +- Whether `updated_at` should reflect metadata-only changes such as archive and + pin operations or only transcript changes. +- How to resolve conflicts when CLI and WebUI update titles, archive state, or + project/profile ownership concurrently. +- Whether imported CLI sessions remain read-only projections or become editable + unified records. +- How unified records should preserve WebUI recovery safeguards such as backup + creation before transcript shrinkage. +- Which store owns sidebar ordering once JSON and SessionDB records coexist. + +## Out Of Scope + +This spike does not switch runtime WebUI call sites, migrate existing session +files, expose a UI setting, alter CLI storage, change session import behavior, or +remove any JSON sidecars. It is a contract and safety test bed for future +migration work. diff --git a/docs/docker.md b/docs/docker.md index 4da354ba6c4..ba2a3a10eef 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -66,6 +66,80 @@ isolated Hermes home and follow > for a one-off root run, use `sudo -E docker compose up -d` and verify the > rendered mount with `docker compose config` first. +## Optional GPU runtime image + +The default Hermes WebUI Docker image stays CPU-only. GPU user-space packages +are installed only when you build a custom image with the opt-in build arg: + +```bash +docker build --build-arg INSTALL_GPU_LIBS=1 -t hermes-webui:gpu . +``` + +That build path installs VA-API basics (`libva2`, `vainfo`), AMD Mesa VA-API +drivers (`mesa-va-drivers`), and the Intel non-free media driver when that +package is available from the configured Debian repositories. NVIDIA host +runtime tooling is not installed into the app image; use the NVIDIA Container +Toolkit on the host and pass GPUs through at runtime. + +GPU passthrough still depends on host drivers, Docker runtime support, and +device mappings. The commands below are configuration guidance for a suitable +Linux Docker host; they are not a claim that native GPU passthrough was verified +in this workspace. + +### Intel and AMD VA-API + +Expose the host render devices and add the runtime user to the common video and +render groups: + +```bash +docker run --rm \ + --device /dev/dri:/dev/dri \ + --group-add video \ + --group-add render \ + hermes-webui:gpu vainfo +``` + +For Compose, add the same mapping to a custom service definition: + +```yaml +services: + hermes-webui: + image: hermes-webui:gpu + devices: + - /dev/dri:/dev/dri + group_add: + - video + - render +``` + +`vainfo` should list the VA-API driver and supported profiles when the host +driver stack and container permissions are correct. The container entrypoint +preserves Docker-provided supplemental groups before it drops privileges to the +`hermeswebui` runtime user, so the WebUI process keeps access to `/dev/dri`. + +### NVIDIA + +Install and configure the NVIDIA Container Toolkit on the host first, then use +Docker's GPU runtime flag: + +```bash +docker run --rm --gpus all hermes-webui:gpu nvidia-smi +``` + +For Compose, use a custom service with GPU access enabled: + +```yaml +services: + hermes-webui: + image: hermes-webui:gpu + gpus: all +``` + +If `nvidia-smi` is unavailable or reports no devices, fix the host NVIDIA driver +and container toolkit setup before debugging Hermes WebUI. The container image +only supplies the WebUI plus optional user-space media libraries; it cannot +provide host kernel drivers or the NVIDIA runtime. + ## Scheduled jobs and the gateway daemon **Symptom**: Cron jobs created in the Tasks panel never fire. System Settings or Tasks shows: @@ -347,6 +421,7 @@ volumes: - #1399 — UID alignment in compose files (fixed in v0.50.260 via PR #1428 + this guide) - #3012 — host `localhost` API URLs fail from Docker containers (use `host.docker.internal` / `host.containers.internal`) - #3006 — `sudo docker compose` can mount `/root/.hermes` instead of the user's Hermes home +- #3243 — optional GPU runtime image/docs for containerized acceleration workloads - #858 — two-container `/opt/hermes` path confusion - #681 — tools running in WebUI container, not agent container (architectural) - #668 — auto-detect UID/GID from mounted volume diff --git a/static/boot.js b/static/boot.js index 780bce3712e..51fb9d1692e 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1842,7 +1842,7 @@ function applyBotName(){ const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1'; if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){ const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':''); - api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{}); + api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false})}).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{}); } // Fetch active profile try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';S.activeProfileIsDefault=!!p.is_default;}catch(e){S.activeProfile='default';S.activeProfileIsDefault=true;} diff --git a/static/commands.js b/static/commands.js index e196e3425de..5fb97d30a4d 100644 --- a/static/commands.js +++ b/static/commands.js @@ -572,13 +572,23 @@ async function cmdWorkspace(args){ } async function cmdTerminal(){ + let data=null; + try{ + data=await api('/api/workspaces'); + if(typeof syncTerminalBackendState==='function') syncTerminalBackendState(data); + if(data&&data.terminal_remote_backend){ + const msg=typeof _terminalRemoteBackendUnsupportedMessage==='function' + ? _terminalRemoteBackendUnsupportedMessage() + : 'Embedded terminal is only supported for local terminal backends.'; + showToast(msg,3200,'warning'); + if(typeof syncTerminalButton==='function') syncTerminalButton(); + return; + } + }catch(_){} if(!S.session&&typeof newSession==='function'){ if(!S._profileSwitchWorkspace&&!S._profileDefaultWorkspace){ - try{ - const data=await api('/api/workspaces'); - const first=(data.workspaces||[])[0]; - S._profileSwitchWorkspace=data.last||(first&&first.path)||null; - }catch(_){} + const first=(data&&data.workspaces||[])[0]; + S._profileSwitchWorkspace=(data&&data.last)||(first&&first.path)||null; } await newSession(); if(typeof renderSessionList==='function') await renderSessionList(); diff --git a/static/i18n.js b/static/i18n.js index 51d19b55cff..f6fb622db03 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -383,6 +383,7 @@ const LOCALES = { terminal_input_failed: 'Terminal input failed: ', terminal_copy_failed: 'Copy failed: ', terminal_error: 'Terminal error', + terminal_remote_backend_unsupported: 'Embedded terminal is only supported for local terminal backends.', workspace_empty_no_path: 'No workspace selected. Set a workspace in Settings \u2192 Workspace to browse files.', workspace_empty_dir: 'This workspace is empty.', workspace_show_hidden_files: 'Show hidden files', @@ -1725,6 +1726,7 @@ const LOCALES = { terminal_input_failed: 'Input terminale fallito: ', terminal_copy_failed: 'Copia fallita: ', terminal_error: 'Errore terminale', + terminal_remote_backend_unsupported: 'Il terminale incorporato è supportato solo per backend terminali locali.', workspace_empty_no_path: 'Nessun workspace selezionato. Imposta un workspace in Impostazioni \u2192 Workspace per esplorare i file.', workspace_empty_dir: 'Questo workspace è vuoto.', workspace_show_hidden_files: 'Mostra file nascosti', @@ -3061,6 +3063,7 @@ const LOCALES = { terminal_input_failed: 'ターミナル入力失敗: ', terminal_copy_failed: 'コピー失敗: ', terminal_error: 'ターミナルエラー', + terminal_remote_backend_unsupported: '埋め込みターミナルはローカルのターミナルバックエンドでのみ利用できます。', workspace_empty_no_path: 'ワークスペースが選択されていません。設定 → ワークスペースで選択してください。', workspace_empty_dir: 'このワークスペースは空です。', workspace_show_hidden_files: '隠しファイルを表示', @@ -4330,6 +4333,7 @@ const LOCALES = { terminal_input_failed: 'Ошибка ввода терминала: ', terminal_copy_failed: 'Не удалось скопировать: ', terminal_error: 'Ошибка терминала', + terminal_remote_backend_unsupported: 'Встроенный терминал поддерживается только для локальных терминальных бэкендов.', dialog_confirm_title: 'Подтвердить действие', dialog_prompt_title: 'Введите значение', dialog_confirm_btn: 'Подтвердить', @@ -5582,6 +5586,7 @@ const LOCALES = { terminal_input_failed: 'Error de entrada del terminal: ', terminal_copy_failed: 'Error al copiar: ', terminal_error: 'Error del terminal', + terminal_remote_backend_unsupported: 'El terminal integrado solo es compatible con backends de terminal locales.', workspace_empty_no_path: 'No hay espacio de trabajo seleccionado. Configure un espacio de trabajo en Ajustes \u2192 Workspace para explorar archivos.', workspace_empty_dir: 'Este espacio de trabajo está vacío.', workspace_show_hidden_files: 'Mostrar archivos ocultos', @@ -6832,6 +6837,7 @@ const LOCALES = { terminal_input_failed: 'Terminal-Eingabe fehlgeschlagen: ', terminal_copy_failed: 'Kopieren fehlgeschlagen: ', terminal_error: 'Terminalfehler', + terminal_remote_backend_unsupported: 'Das eingebettete Terminal wird nur für lokale Terminal-Backends unterstützt.', workspace_empty_no_path: 'Kein Workspace ausgewählt. Wähle einen Workspace unter Einstellungen \u2192 Workspace, um Dateien zu durchsuchen.', workspace_empty_dir: 'Dieser Workspace ist leer.', workspace_show_hidden_files: 'Versteckte Dateien anzeigen', @@ -8149,6 +8155,7 @@ const LOCALES = { terminal_input_failed: '终端输入失败:', terminal_copy_failed: '复制失败:', terminal_error: '终端错误', + terminal_remote_backend_unsupported: '嵌入式终端仅支持本地终端后端。', dialog_confirm_title: '确认操作', dialog_prompt_title: '输入内容', dialog_confirm_btn: '确认', @@ -9498,6 +9505,7 @@ const LOCALES = { terminal_input_failed: 'Terminal 輸入失敗:', terminal_copy_failed: '複製失敗:', terminal_error: 'Terminal 錯誤', + terminal_remote_backend_unsupported: '嵌入式 Terminal 僅支援本機終端後端。', workspace_empty_no_path: '未選擇工作區。請在 設定 → 工作區 中設定工作區以瀏覽檔案。', workspace_empty_dir: '此工作區為空。', workspace_show_hidden_files: '顯示隱藏檔案', @@ -11952,6 +11960,7 @@ const LOCALES = { terminal_input_failed: '터미널 입력 실패: ', terminal_copy_failed: '복사 실패: ', terminal_error: '터미널 오류', + terminal_remote_backend_unsupported: '임베디드 터미널은 로컬 터미널 백엔드에서만 지원됩니다.', workspace_empty_no_path: 'No workspace selected. Set a workspace in Settings \u2192 Workspace to browse files.', workspace_empty_dir: 'This workspace is empty.', workspace_show_hidden_files: '숨김 파일 표시', @@ -13223,6 +13232,7 @@ const LOCALES = { terminal_input_failed: 'L\'entrée du terminal a échoué :', terminal_copy_failed: 'Échec de la copie :', terminal_error: 'Erreur de terminal', + terminal_remote_backend_unsupported: 'Le terminal intégré est uniquement pris en charge avec des backends de terminal locaux.', workspace_empty_no_path: 'Aucun espace de travail sélectionné. Définissez un espace de travail dans Paramètres \u2192 Espace de travail pour parcourir les fichiers.', workspace_empty_dir: 'Cet espace de travail est vide.', workspace_show_hidden_files: 'Afficher les fichiers cachés', @@ -14567,6 +14577,7 @@ const LOCALES = { terminal_input_failed: 'Terminal girişi başarısız oldu:', terminal_copy_failed: 'Kopyalama başarısız oldu:', terminal_error: 'Terminal hatası', + terminal_remote_backend_unsupported: 'Gömülü terminal yalnızca yerel terminal arka uçlarında desteklenir.', workspace_empty_no_path: 'Çalışma alanı seçilmedi. Dosyalara göz atmak için Ayarlar \u2192 Çalışma Alanı\'nda bir çalışma alanı ayarlayın.', workspace_empty_dir: 'Bu çalışma alanı boş.', workspace_show_hidden_files: 'Gizli dosyaları göster', diff --git a/static/panels.js b/static/panels.js index e688376a2a5..da554d0f913 100644 --- a/static/panels.js +++ b/static/panels.js @@ -726,9 +726,20 @@ async function _loadRunContent(jobId, filename, runId){ const body = document.querySelector(`#${runId} .detail-run-body`); if (!body) return; const item = document.getElementById(runId); - if (!item.classList.contains('open')) { - item.classList.add('open'); + if (item.classList.contains('open')) { + // Already open → collapse and return (toggle behaviour) + item.classList.remove('open'); + body.classList.remove('expanded'); + _cronExpansionSet(_cronRunExpandKey(jobId, filename), false); + const btn = item ? item.querySelector('.detail-expand-toggle') : null; + if (btn) { + btn.textContent = '▾'; + btn.title = (t('cron_expand_output') || 'Expand output'); + btn.setAttribute('aria-label', btn.title); + } + return; } + item.classList.add('open'); body.classList.toggle('expanded', _cronExpansionGet(_cronRunExpandKey(jobId, filename))); body.innerHTML = `${esc(t('loading'))}`; try { @@ -4395,8 +4406,10 @@ function syncWorkspaceDisplays(){ async function loadWorkspaceList(){ try{ const data = await api('/api/workspaces'); + if(typeof syncTerminalBackendState==='function') syncTerminalBackendState(data); _workspaceList = data.workspaces || []; syncWorkspaceDisplays(); + if(typeof syncTerminalButton==='function') syncTerminalButton(); return data; }catch(e){ return {workspaces:[], last:''}; } } @@ -7592,7 +7605,7 @@ async function checkUpdatesNow(){ if(label) label.textContent=t('settings_checking'); if(status) status.textContent=''; try { - const data=await api('/api/updates/check?force=1',{timeoutMs:60000}); + const data=await api('/api/updates/check',{method:'POST',body:JSON.stringify({force:true}),timeoutMs:60000}); if(data.disabled){ if(status){status.textContent=t('settings_updates_disabled');status.style.color='var(--muted)';} } else { diff --git a/static/sessions.js b/static/sessions.js index cff71d20d77..3c6c0c0851f 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -961,9 +961,24 @@ async function loadSession(sid){ // replaying persisted live tools so the compact Activity count survives // switching away from and back to an active chat (#1715). S.activeStreamId=activeStreamId; + const liveToolReplayId=(tc)=>String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim(); + const replayPersistedLiveToolCards=(opts)=>{ + const liveToolCalls=Array.isArray(S.toolCalls) + ? S.toolCalls + : (Array.isArray(INFLIGHT[sid]&&INFLIGHT[sid].toolCalls)?INFLIGHT[sid].toolCalls:[]); + const skipUnkeyedRestoredDuplicates=!!(opts&&opts.skipUnkeyedRestoredDuplicates); + const restoredLiveTurn=skipUnkeyedRestoredDuplicates?document.getElementById('liveAssistantTurn'):null; + const hasRestoredLiveToolRows=!!(restoredLiveTurn&&restoredLiveTurn.querySelector('.tool-card-row')); + for(const tc of (liveToolCalls||[])){ + if(skipUnkeyedRestoredDuplicates&&hasRestoredLiveToolRows&&!liveToolReplayId(tc)) continue; + if(tc&&tc.name) appendLiveToolCard(tc,{sessionId:sid,streamId:activeStreamId}); + } + }; + let didReconnect=false; if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){ INFLIGHT[sid].reattach=false; if (_loadingSessionId !== sid) return; + didReconnect=true; attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true}); } syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined); @@ -992,14 +1007,15 @@ async function loadSession(sid){ else restoredLiveTurn=restoreLiveTurnHtmlForSession(sid); } } + if(restoredLiveTurn&&didReconnect){ + replayPersistedLiveToolCards({skipUnkeyedRestoredDuplicates:true}); + } if(!restoredLiveTurn){ clearLiveToolCards(); if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost(); if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell(); else appendThinking(); - for(const tc of (S.toolCalls||[])){ - if(tc&&tc.name) appendLiveToolCard(tc); - } + replayPersistedLiveToolCards(); } if(typeof ensureLiveWorklogShell==='function'){ const liveTurn=document.getElementById('liveAssistantTurn'); @@ -4678,6 +4694,50 @@ function renderSessionListFromCache(){ }; chip.ondblclick=(e)=>{e.stopPropagation();clearTimeout(_pClickTimer);_pClickTimer=null;_startProjectRename(p,chip);}; chip.oncontextmenu=(e)=>{e.preventDefault();_showProjectContextMenu(e,p,chip);}; + // Touch long-press → context menu (mobile UX: project chips can only be + // deleted via the right-click menu, which has no touch equivalent). + let _lpTimer=null; + let _lpHandled=false; + let _lpStartX=0,_lpStartY=0; + chip.addEventListener('touchstart',(e)=>{ + const t=e.changedTouches&&e.changedTouches[0]; + if(!t) return; + // Clear any in-flight timer before scheduling a new one, mirroring the + // session-item long-press path (_clearLongPressTimer). Without this a + // second finger / stray touchstart orphans the prior timer, which then + // fires unsuppressed ~500ms later and pops the menu after the gesture + // was cancelled. + if(_lpTimer){clearTimeout(_lpTimer);_lpTimer=null;} + _lpHandled=false;_lpStartX=t.clientX;_lpStartY=t.clientY; + chip.classList.add('long-pressing'); + _lpTimer=setTimeout(()=>{ + _lpTimer=null; + if(_lpHandled) return; // already consumed by another gesture — stale fire is a no-op + _lpHandled=true; + chip.classList.remove('long-pressing'); + clearTimeout(_pClickTimer);_pClickTimer=null; + const syn={clientX:t.clientX,clientY:t.clientY,preventDefault:()=>{}}; + _showProjectContextMenu(syn,p,chip); + },500); + },{passive:true}); + chip.addEventListener('touchmove',(e)=>{ + if(!_lpTimer) return; + const t=e.changedTouches&&e.changedTouches[0]; + if(!t) return; + if(Math.abs(t.clientX-_lpStartX)>10||Math.abs(t.clientY-_lpStartY)>10){ + clearTimeout(_lpTimer);_lpTimer=null; + chip.classList.remove('long-pressing'); + } + },{passive:true}); + chip.addEventListener('touchend',(e)=>{ + clearTimeout(_lpTimer);_lpTimer=null; + chip.classList.remove('long-pressing'); + if(_lpHandled){e.preventDefault();e.stopPropagation();} + },{passive:false}); + chip.addEventListener('touchcancel',()=>{ + clearTimeout(_lpTimer);_lpTimer=null;_lpHandled=false; + chip.classList.remove('long-pressing'); + },{passive:true}); bar.appendChild(chip); } // Create button @@ -5191,8 +5251,11 @@ function renderSessionListFromCache(){ } if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);} }; - // onblur: cancel only -- no accidental saves - inp.onblur=()=>{ if(_renamingSid===s.session_id) finish(false); }; + // onblur: save on blur — Escape explicitly cancels. The old cancel-on-blur + // behavior broke rename on mobile (iPhone "Done" dismisses the keyboard, + // triggering blur) and was less natural on desktop too (typing a name then + // clicking elsewhere should save, not discard). + inp.onblur=()=>{ if(_renamingSid===s.session_id) finish(true); }; title.replaceWith(inp); setTimeout(()=>{inp.focus();inp.select();},10); }; @@ -5883,10 +5946,19 @@ function _startProjectCreate(bar, addBtn){ const inp=document.createElement('input'); inp.className='project-create-input'; inp.placeholder='Project name'; + let _finishDone=false; const finish=async(save)=>{ + if(_finishDone) return; + _finishDone=true; if(save&&inp.value.trim()){ const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length]; - await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})}); + try{ + await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})}); + }catch(e){ + _finishDone=false; + showToast('Project create failed: '+(e.message||e)); + return; + } await renderSessionList(); showToast('Project created'); }else{ @@ -5901,7 +5973,7 @@ function _startProjectCreate(bar, addBtn){ } if(e.key==='Escape'){e.preventDefault();finish(false);} }; - inp.onblur=()=>finish(false); + inp.onblur=()=>finish(true); inp.addEventListener('input',()=>_resizeProjectInput(inp)); addBtn.replaceWith(inp); _resizeProjectInput(inp); @@ -5912,13 +5984,17 @@ function _startProjectRename(proj, chip){ const inp=document.createElement('input'); inp.className='project-create-input'; inp.value=proj.name; + let _finishDone=false; const finish=async(save)=>{ + if(_finishDone) return; + _finishDone=true; if(save&&inp.value.trim()&&inp.value.trim()!==proj.name){ try { await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})}); await renderSessionList(); showToast('Project renamed'); } catch(e) { + _finishDone=false; showToast('Rename failed: '+(e.message||e)); } }else{ @@ -5933,7 +6009,7 @@ function _startProjectRename(proj, chip){ } if(e.key==='Escape'){e.preventDefault();finish(false);} }; - inp.onblur=()=>finish(false); + inp.onblur=()=>finish(true); inp.onclick=(e)=>e.stopPropagation(); inp.addEventListener('input',()=>_resizeProjectInput(inp)); chip.replaceWith(inp); diff --git a/static/style.css b/static/style.css index a9347642d58..5c25a1579d4 100644 --- a/static/style.css +++ b/static/style.css @@ -1212,8 +1212,8 @@ .app-dialog-btn.confirm.danger{border-color:var(--error);background:rgba(239,83,80,.12);color:var(--error);} .app-dialog-btn.confirm.danger:hover{background:rgba(239,83,80,.2);border-color:var(--error);} .app-dialog-btn:focus-visible,.app-dialog-close:focus-visible{outline:2px solid var(--accent);outline-offset:2px;} - .toast{pointer-events:auto;position:fixed;top:24px;right:24px;left:auto;bottom:auto;transform:translateY(-6px);display:flex;align-items:center;gap:10px;background:color-mix(in srgb,var(--accent) 14%,var(--surface));border:1px solid color-mix(in srgb,var(--accent) 45%,var(--surface));color:var(--accent-text);font-size:13px;font-weight:500;padding:10px 12px 10px 16px;border-radius:10px;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 6px 24px rgba(0,0,0,.12);letter-spacing:.01em;max-width:min(520px,calc(100vw - 48px));} - .toast.show{opacity:1;transform:translateY(0);} + .toast{pointer-events:none;position:fixed;top:24px;right:24px;left:auto;bottom:auto;transform:translateY(-6px);display:flex;align-items:center;gap:10px;background:color-mix(in srgb,var(--accent) 14%,var(--surface));border:1px solid color-mix(in srgb,var(--accent) 45%,var(--surface));color:var(--accent-text);font-size:13px;font-weight:500;padding:10px 12px 10px 16px;border-radius:10px;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 6px 24px rgba(0,0,0,.12);letter-spacing:.01em;max-width:min(520px,calc(100vw - 48px));} + .toast.show{opacity:1;transform:translateY(0);pointer-events:auto;} .toast.success{background:color-mix(in srgb,var(--success) 14%,var(--surface));border-color:color-mix(in srgb,var(--success) 45%,var(--surface));color:var(--success);} .toast.error{background:color-mix(in srgb,var(--error) 14%,var(--surface));border-color:color-mix(in srgb,var(--error) 45%,var(--surface));color:var(--error);} .toast-message{min-width:0;overflow-wrap:anywhere;white-space:pre-wrap;} @@ -4110,9 +4110,10 @@ main.main > #mainPlugin{display:none;} .session-source-tab.active{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg);} .session-empty-note{padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;} .project-bar{display:flex;gap:4px;padding:4px 10px 8px;flex-wrap:wrap;align-items:center;flex-shrink:0;} -.project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;} +.project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none;touch-action:manipulation;} .project-chip:hover{background:rgba(255,255,255,.08);color:var(--text);} .project-chip.active{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg);} +.project-chip.long-pressing{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg-strong);transform:scale(1.05);} /* "Unassigned" filter chip — dashed border distinguishes it from real project chips so it reads as a meta-filter ("things without a project") rather than another project. Keeps full color treatment in the active diff --git a/static/terminal.js b/static/terminal.js index bb18b36eaae..5f46d650bb2 100644 --- a/static/terminal.js +++ b/static/terminal.js @@ -346,6 +346,32 @@ function _setTerminalChromeState(state){ if(dockWorkspace)dockWorkspace.textContent=label; } +function syncTerminalBackendState(data){ + S.terminalRemoteBackend=!!(data&&data.terminal_remote_backend); + return S.terminalRemoteBackend; +} + +function _terminalRemoteBackendUnsupportedMessage(){ + const key=t('terminal_remote_backend_unsupported'); + return key&&key!=='terminal_remote_backend_unsupported' + ? key + : 'Embedded terminal is only supported for local terminal backends.'; +} + +function _terminalStartErrorMessage(err){ + if(err&&err.body){ + try{ + const payload=JSON.parse(err.body); + if(payload&&payload.error==='remote_terminal_backend_unsupported'){ + S.terminalRemoteBackend=true; + syncTerminalButton(); + return String(payload.message||_terminalRemoteBackendUnsupportedMessage()); + } + }catch(_){} + } + return err&&err.message?err.message:String(err||''); +} + function syncTerminalButton(){ const {toggle}= _terminalEls(); const currentSid=_terminalSessionId(); @@ -355,10 +381,15 @@ function syncTerminalButton(){ } if(!toggle)return; const hasWorkspace=!!(S.session&&S.session.workspace); - toggle.disabled=!hasWorkspace; + const remoteBackend=!!S.terminalRemoteBackend; + toggle.disabled=!hasWorkspace||remoteBackend; toggle.classList.toggle('active',TERMINAL_UI.open); toggle.setAttribute('aria-pressed',TERMINAL_UI.open?'true':'false'); - toggle.title=hasWorkspace?(TERMINAL_UI.collapsed?t('terminal_expand'):t('terminal_open_title')):t('terminal_no_workspace_title'); + toggle.title=!hasWorkspace + ? t('terminal_no_workspace_title') + : (remoteBackend + ? _terminalRemoteBackendUnsupportedMessage() + : (TERMINAL_UI.collapsed?t('terminal_expand'):t('terminal_open_title'))); toggle.setAttribute('aria-label',toggle.title); } @@ -409,16 +440,26 @@ async function _startComposerTerminal(restart=false){ syncTerminalButton(); return; } + if(S.terminalRemoteBackend){ + showToast(_terminalRemoteBackendUnsupportedMessage(),3200,'warning'); + syncTerminalButton(); + return; + } const term=_ensureXterm(); if(!term)return; _fitTerminal(); const dims=_terminalDimensions(); - await api('/api/terminal/start',{method:'POST',body:JSON.stringify({ - session_id:sid, - rows:dims.rows, - cols:dims.cols, - restart:!!restart, - })}); + try{ + await api('/api/terminal/start',{method:'POST',body:JSON.stringify({ + session_id:sid, + rows:dims.rows, + cols:dims.cols, + restart:!!restart, + })}); + }catch(e){ + e.message=_terminalStartErrorMessage(e); + throw e; + } TERMINAL_UI.sessionId=sid; TERMINAL_UI.workspace=S.session&&S.session.workspace||null; TERMINAL_UI.typedLine=''; diff --git a/static/ui.js b/static/ui.js index da3844b5655..96bc18a8eb8 100644 --- a/static/ui.js +++ b/static/ui.js @@ -5720,12 +5720,22 @@ function _normalizeHealthServerIdentity(rawIdentity){ return Number.isFinite(numeric) ? String(numeric) : null; } +function _healthResponseServerIdentity(data){ + if(!data||typeof data!=='object') return null; + const serverStartedAt=_normalizeHealthServerIdentity(data.server_started_at); + const hasUptimeSeconds=data.uptime_seconds!==null&&data.uptime_seconds!==undefined; + const uptimeSeconds=hasUptimeSeconds?Number(data.uptime_seconds):NaN; + const normalizedUptime=Number.isFinite(uptimeSeconds)&&uptimeSeconds>=0 ? uptimeSeconds : null; + if(serverStartedAt===null&&normalizedUptime===null) return null; + return {serverStartedAt,uptimeSeconds:normalizedUptime}; +} + async function _readHealthServerIdentity() { try { const r=await fetch(new URL('health', document.baseURI||location.href).href,{cache:'no-store'}); if(!r.ok) return null; const data=await r.json(); - return _normalizeHealthServerIdentity(data&&data.server_started_at); + return _healthResponseServerIdentity(data); } catch (_) { return null; } @@ -5770,13 +5780,36 @@ async function _waitForServerThenReload(opts){ opts=opts||{}; const interval=opts.interval||500; const maxMs=opts.maxMs||15000; - const baselineServerIdentity=_normalizeHealthServerIdentity(opts.baselineServerIdentity); + const baselineServerIdentity=(()=>{ + const rawIdentity=opts.baselineServerIdentity; + if(!rawIdentity||typeof rawIdentity!=='object'){ + const normalizedServerStartedAt=_normalizeHealthServerIdentity(rawIdentity); + return normalizedServerStartedAt===null ? null : {serverStartedAt:normalizedServerStartedAt,uptimeSeconds:null}; + } + const normalizedIdentity={ + serverStartedAt:_normalizeHealthServerIdentity(rawIdentity.serverStartedAt), + uptimeSeconds:Number.isFinite(Number(rawIdentity.uptimeSeconds))&&Number(rawIdentity.uptimeSeconds)>=0 ? Number(rawIdentity.uptimeSeconds) : null, + }; + return normalizedIdentity.serverStartedAt===null&&normalizedIdentity.uptimeSeconds===null ? null : normalizedIdentity; + })(); window._restartingForUpdate=true; const msgEl=$('reconnectMsg'); const banner=$('reconnectBanner'); if(msgEl) msgEl.textContent='⏳ Restarting… please wait'; if(banner) banner.classList.add('visible'); const deadline=Date.now()+maxMs; + // Track restart-outage evidence. An outage (failed or non-OK /health probes) + // followed by a healthy response is a reliable new-instance signal even when + // only uptime_seconds is comparable and the replacement's uptime is not strictly + // lower than the captured baseline (e.g. a deployment that strips + // server_started_at and whose baseline uptime was very low). We require at least + // TWO consecutive outage probes before trusting it, so a single transient network + // blip (with the OLD process still up and its uptime merely increasing) cannot + // trigger a premature reload onto the old server. Both thrown fetch errors AND + // non-OK responses (e.g. a reverse-proxy 502/503 during restart) count as outage + // evidence. (#3713 Codex catches) + let _consecutiveOutages=0; + const _restartOutageObserved=()=>_consecutiveOutages>=2; // Give the server a moment to actually begin its restart before the first // probe — otherwise the old process may still respond ok on the first poll. await new Promise(r=>setTimeout(r, interval)); @@ -5787,19 +5820,78 @@ async function _waitForServerThenReload(opts){ let data={}; try{ data=await r.json(); }catch(_){} if(data && data.status==='ok'){ - const nextServerIdentity=_normalizeHealthServerIdentity(data&&data.server_started_at); + const nextServerIdentity=_healthResponseServerIdentity(data); if (baselineServerIdentity===null){ location.reload(); return; } - if (nextServerIdentity!==null && nextServerIdentity !== baselineServerIdentity){ + if( + nextServerIdentity===null && + ( + baselineServerIdentity.serverStartedAt!==null || + baselineServerIdentity.uptimeSeconds!==null + ) + ){ + // If the replacement server comes back healthy without either + // identity field after the baseline exposed a comparable identity, + // treat that healthy response as the new server instead of timing + // out on an uncomparable identity shape. + location.reload(); + return; + } + if( + nextServerIdentity!==null && + baselineServerIdentity.serverStartedAt!==null && + nextServerIdentity.serverStartedAt===null && + nextServerIdentity.uptimeSeconds!==null + ){ + // If the baseline exposed server_started_at but the replacement + // health response degrades to uptime-only, there is no longer a + // comparable started_at field. Treat the first healthy uptime-only + // response as the new server instead of timing out. + location.reload(); + return; + } + if( + nextServerIdentity!==null&&( + (baselineServerIdentity.serverStartedAt===null&&nextServerIdentity.serverStartedAt!==null)|| + (baselineServerIdentity.serverStartedAt!==null&&nextServerIdentity.serverStartedAt!==null&&nextServerIdentity.serverStartedAt!==baselineServerIdentity.serverStartedAt)|| + (baselineServerIdentity.uptimeSeconds!==null&&nextServerIdentity.uptimeSeconds!==null&&nextServerIdentity.uptimeSeconds=2 consecutive failed/non-OK probes) before this healthy response: + // treat that outage as the restart, so reload even though the + // replacement uptime is not strictly lower than a very-low baseline. location.reload(); return; } - // Keep polling while the server keeps reporting the same (pre-restart) process identity + // Healthy response still describing the pre-restart process: this is the + // OLD server answering, so any earlier outage was a transient blip, not a + // restart — reset the outage evidence so it can't accumulate into a false + // positive across unrelated blips. + _consecutiveOutages=0; + // Keep polling while /health still describes the pre-restart process. + }else{ + // Reachable but not status:ok (still starting up) — counts as outage. + _consecutiveOutages++; } + }else{ + // Non-OK HTTP (e.g. reverse-proxy 502/503 during restart) — outage evidence. + _consecutiveOutages++; } - }catch(_){ /* socket closed during restart — retry */ } + }catch(_){ _consecutiveOutages++; /* socket closed during restart — retry */ } await new Promise(r=>setTimeout(r, interval)); } if(msgEl) msgEl.textContent='⚠️ Server is taking longer than expected — click Reload when ready'; @@ -9165,7 +9257,7 @@ function appendLiveToolCard(tc){ } const inner=_assistantTurnBlocks(turn); if(!inner) return; - const tid=tc.tid||''; + const tid=tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||''; const children=Array.from(inner.children); const burstId=tc.activityBurstId!==undefined&&tc.activityBurstId!==null&&String(tc.activityBurstId)!=='0'?String(tc.activityBurstId):''; const segmentSeq=tc.activitySegmentSeq!==undefined&&tc.activitySegmentSeq!==null&&String(tc.activitySegmentSeq)!=='0'?String(tc.activitySegmentSeq):''; diff --git a/tests/browser_smoke.py b/tests/browser_smoke.py index 7d84ae04631..e606731a91c 100644 --- a/tests/browser_smoke.py +++ b/tests/browser_smoke.py @@ -114,6 +114,7 @@ def main(): proc = subprocess.Popen( [sys.executable, server_py], cwd=repo_root, env=env, stdout=log, stderr=subprocess.STDOUT, + **({"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}), ) try: if not _wait_for_health(timeout=30): diff --git a/tests/conftest.py b/tests/conftest.py index 10085b6ec79..cebbd129155 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -672,6 +672,7 @@ def test_server(): env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + **({"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}), ) if not _wait_for_server(TEST_BASE, timeout=20): diff --git a/tests/test_api_timeout.py b/tests/test_api_timeout.py index 61788883a22..d5fb671d1a7 100644 --- a/tests/test_api_timeout.py +++ b/tests/test_api_timeout.py @@ -211,7 +211,7 @@ def test_update_flows_keep_explicit_longer_timeouts(): """Legitimately long update flows should not inherit the generic 30s guard.""" src = _source(UI_JS) panels = _source(PANELS_JS) - assert "api('/api/updates/check?force=1',{timeoutMs:60000})" in panels + assert "api('/api/updates/check',{method:'POST',body:JSON.stringify({force:true}),timeoutMs:60000})" in panels assert "api('/api/updates/summary',{method:'POST',body:JSON.stringify({updates:scopedUpdates,target:target||null}),timeoutMs:60000})" in src assert "api('/api/updates/apply',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000})" in src assert "api('/api/updates/force',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000})" in src diff --git a/tests/test_byok_model_dropdown.py b/tests/test_byok_model_dropdown.py index 835d17c8f32..451a3c08965 100644 --- a/tests/test_byok_model_dropdown.py +++ b/tests/test_byok_model_dropdown.py @@ -23,6 +23,8 @@ sys.path.insert(0, str(REPO)) sys.path.insert(0, str(REPO.parent / ".hermes" / "hermes-agent")) +from api.config import CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS + def read(rel): return (REPO / rel).read_text(encoding="utf-8") @@ -366,7 +368,7 @@ def fake_urlopen(req, timeout=None): { "url": "https://right.codes/codex/v1/models", "authorization": "Bearer right-key", - "timeout": 8, + "timeout": CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS, } ] assert [m["id"] for m in resp["models"]] == ["right-live-model"] diff --git a/tests/test_ctl_script.py b/tests/test_ctl_script.py index fc4dfff5c6c..7827bdf368a 100644 --- a/tests/test_ctl_script.py +++ b/tests/test_ctl_script.py @@ -143,7 +143,10 @@ def windows_pid(pid: int) -> int | None: def start_fake_launchd_process() -> subprocess.Popen: - return subprocess.Popen(["bash", "-lc", "exec sleep 30"]) + return subprocess.Popen( + ["bash", "-lc", "exec sleep 30"], + **({"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}), + ) def _kill_tree(pid: int) -> None: @@ -316,7 +319,10 @@ def test_stale_pid_file_is_removed_without_killing_unrelated_process(tmp_path): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() pid_file = hermes_home / "webui.pid" - sleeper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + sleeper = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + **({"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}), + ) try: pid_file.write_text(str(sleeper.pid), encoding="utf-8") result = run_ctl(tmp_path, "stop") diff --git a/tests/test_docker_gpu_runtime_docs.py b/tests/test_docker_gpu_runtime_docs.py new file mode 100644 index 00000000000..95c2766e13e --- /dev/null +++ b/tests/test_docker_gpu_runtime_docs.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + + +def _repo_text(path): + return (REPO / path).read_text(encoding="utf-8") + + +def test_dockerfile_gpu_libraries_are_opt_in(): + """The production image must stay CPU-only unless the GPU build arg is set.""" + dockerfile = _repo_text("Dockerfile") + + assert "ARG INSTALL_GPU_LIBS=0" in dockerfile + assert 'if [ "$INSTALL_GPU_LIBS" = "1" ]' in dockerfile + + opt_in_block = dockerfile[dockerfile.index("ARG INSTALL_GPU_LIBS=0"):] + for package in ( + "libva2", + "vainfo", + "mesa-va-drivers", + "intel-media-va-driver-non-free", + ): + assert package in opt_in_block, ( + f"{package} must only appear in the INSTALL_GPU_LIBS opt-in block." + ) + assert package not in dockerfile[:dockerfile.index("ARG INSTALL_GPU_LIBS=0")] + + +def test_dockerfile_handles_missing_intel_non_free_driver(): + """Debian slim repos may not expose the non-free Intel VA-API package.""" + dockerfile = _repo_text("Dockerfile") + + assert "apt-cache show intel-media-va-driver-non-free" in dockerfile + assert "skipping Intel non-free VA-API driver" in dockerfile + + +def test_docker_docs_show_gpu_build_command(): + docker_docs = _repo_text("docs/docker.md") + + assert "Optional GPU runtime image" in docker_docs + assert "--build-arg INSTALL_GPU_LIBS=1" in docker_docs + assert "default Hermes WebUI Docker image stays CPU-only" in docker_docs + + +def test_docker_docs_cover_intel_amd_dri_mapping(): + docker_docs = _repo_text("docs/docker.md") + + assert "Intel and AMD VA-API" in docker_docs + assert "--device /dev/dri:/dev/dri" in docker_docs + assert "/dev/dri:/dev/dri" in docker_docs + assert "group_add:" in docker_docs + assert "video" in docker_docs + assert "render" in docker_docs + assert "vainfo" in docker_docs + assert "preserves Docker-provided supplemental groups" in docker_docs + + +def test_docker_docs_cover_nvidia_host_runtime_guidance(): + docker_docs = _repo_text("docs/docker.md") + + assert "NVIDIA Container Toolkit" in docker_docs + assert "--gpus all" in docker_docs + assert "gpus: all" in docker_docs + assert "host NVIDIA driver" in docker_docs + assert "host kernel drivers" in docker_docs + assert "NVIDIA runtime" in docker_docs + + +def test_docker_docs_do_not_claim_native_gpu_passthrough_verification(): + docker_docs = _repo_text("docs/docker.md") + + assert "not a claim that native GPU passthrough was verified" in docker_docs + assert "depends on host drivers" in docker_docs + + +def test_docker_init_preserves_supplemental_device_groups_for_runtime_user(): + docker_init = _repo_text("docker_init.bash") + root_phase = docker_init[:docker_init.index("exec su -s /bin/bash")] + + assert "for gid in $(id -G)" in root_phase + assert "groupadd -g \"$gid\"" in root_phase + assert "Could not create supplemental group for GID $gid" in root_phase + assert "usermod -a -G \"$group_name\" hermeswebui" in root_phase + assert "Docker --group-add supplemental groups" in root_phase + + +def test_changelog_mentions_optional_gpu_runtime_path(): + changelog = _repo_text("CHANGELOG.md") + unreleased = changelog[changelog.index("## [Unreleased]"):changelog.index("## [v0.51.293]")] + + assert "Optional GPU runtime image path" in unreleased + assert "INSTALL_GPU_LIBS=1" in unreleased + assert "supplemental device groups" in unreleased diff --git a/tests/test_embedded_workspace_terminal.py b/tests/test_embedded_workspace_terminal.py index 32d8c4eab43..808ec20aa02 100644 --- a/tests/test_embedded_workspace_terminal.py +++ b/tests/test_embedded_workspace_terminal.py @@ -1,5 +1,11 @@ import os import pathlib +import io +import json +from types import SimpleNamespace +from urllib.parse import urlsplit + +import pytest REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() @@ -156,6 +162,17 @@ def test_terminal_slash_command_expands_existing_collapsed_terminal(): assert "else focusComposerTerminalInput();" in toggle_block +def test_terminal_slash_command_preflights_remote_backend_before_session_create(): + commands_js = _read("static/commands.js") + + cmd_block = commands_js.split("async function cmdTerminal", 1)[1].split("async function cmdNew", 1)[0] + assert "api('/api/workspaces')" in cmd_block + assert "syncTerminalBackendState(data)" in cmd_block + assert "data&&data.terminal_remote_backend" in cmd_block + assert "_terminalRemoteBackendUnsupportedMessage" in cmd_block + assert cmd_block.index("data&&data.terminal_remote_backend") < cmd_block.index("await newSession()") + + def test_terminal_v1_does_not_expose_send_to_chat_action(): html = _read("static/index.html") terminal_js = _read("static/terminal.js") @@ -199,6 +216,9 @@ def test_terminal_routes_are_registered(): def test_terminal_process_does_not_mutate_global_terminal_cwd(tmp_path, monkeypatch): from api.terminal import close_terminal, start_terminal + if os.name == "nt": + pytest.skip("Embedded terminal PTY startup is not supported on Windows") + monkeypatch.delenv("TERMINAL_CWD", raising=False) sid = "test-terminal-env" term = start_terminal(sid, tmp_path, rows=8, cols=40, restart=True) @@ -229,3 +249,85 @@ def test_terminal_xterm_theme_follows_appearance_tokens(): assert "attributeFilter:['class','data-skin']" in terminal_js assert "background:var(--code-bg)" in style_css assert "color:var(--pre-text)" in style_css + + +def test_terminal_button_and_start_path_respect_remote_backend_guard(): + terminal_js = _read("static/terminal.js") + + sync_block = terminal_js.split("function syncTerminalButton", 1)[1].split("function focusComposerTerminalInput", 1)[0] + start_block = terminal_js.split("async function _startComposerTerminal", 1)[1].split("async function toggleComposerTerminal", 1)[0] + assert "function syncTerminalBackendState" in terminal_js + assert "function _terminalRemoteBackendUnsupportedMessage" in terminal_js + assert "toggle.disabled=!hasWorkspace||remoteBackend;" in sync_block + assert "_terminalRemoteBackendUnsupportedMessage()" in sync_block + assert "if(S.terminalRemoteBackend)" in start_block + assert "showToast(_terminalRemoteBackendUnsupportedMessage(),3200,'warning');" in start_block + assert "payload&&payload.error==='remote_terminal_backend_unsupported'" in terminal_js + + +class _RouteHandler: + def __init__(self): + self.headers = {} + self.wfile = io.BytesIO() + self.responses = [] + + def send_response(self, status): + self.responses.append(status) + + def send_header(self, _name, _value): + pass + + def end_headers(self): + pass + + +def test_workspaces_route_exposes_terminal_remote_backend_flag(monkeypatch): + import api.routes as routes + + monkeypatch.setattr( + routes, + "load_workspaces", + lambda: [{"path": "/tmp/project", "name": "Project"}], + ) + monkeypatch.setattr(routes, "get_last_workspace", lambda: "/tmp/project") + monkeypatch.setattr(routes, "get_config", lambda: {"terminal": {"backend": "ssh"}}) + + handler = _RouteHandler() + routes.handle_get(handler, urlsplit("/api/workspaces")) + payload = json.loads(handler.wfile.getvalue().decode("utf-8")) + + assert handler.responses == [200] + assert payload["terminal_remote_backend"] is True + assert payload["workspaces"][0]["path"] == "/tmp/project" + assert payload["last"] == "/tmp/project" + + +def test_terminal_start_rejects_remote_backend_with_stale_workspace_before_local_validation(monkeypatch): + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_session", + lambda sid: SimpleNamespace( + session_id=sid, + workspace="/Users/other/projects/stale-remote-workspace", + ), + ) + monkeypatch.setattr( + routes, + "get_config", + lambda: {"terminal": {"backend": "docker", "cwd": "/Users/joeyshiue"}}, + ) + + handler = _RouteHandler() + routes._handle_terminal_start( + handler, + {"session_id": "session-1", "rows": 24, "cols": 80, "restart": False}, + ) + payload = json.loads(handler.wfile.getvalue().decode("utf-8")) + + assert handler.responses == [400] + assert payload == { + "error": "remote_terminal_backend_unsupported", + "message": "Embedded terminal is only supported for local terminal backends.", + } diff --git a/tests/test_gateway_sync.py b/tests/test_gateway_sync.py index 3571d375857..3081ed15864 100644 --- a/tests/test_gateway_sync.py +++ b/tests/test_gateway_sync.py @@ -427,6 +427,144 @@ def test_compression_chain_collapses_to_latest_tip_in_sidebar(): post('/api/settings', {'show_cli_sessions': False}) +def test_compression_lineage_prefers_freshest_descendant_over_newer_direct_sibling(): + """A later-started stale sibling must not hide a deeper active branch.""" + conn = _ensure_state_db() + ids_to_remove = ( + 'branch_root_001', + 'branch_old_mid_001', + 'branch_fresh_tip_001', + 'branch_empty_stale_tip_001', + 'branch_newer_direct_sibling_001', + ) + t0 = time.time() - 800 + try: + _insert_agent_session_row( + conn, + 'branch_root_001', + title='Qwen Routing Audit', + started_at=t0, + ended_at=t0 + 100, + end_reason='compression', + messages=2, + ) + _insert_agent_session_row( + conn, + 'branch_old_mid_001', + title='Qwen Routing Audit #2', + started_at=t0 + 101, + parent_session_id='branch_root_001', + ended_at=t0 + 200, + end_reason='compression', + messages=2, + ) + _insert_agent_session_row( + conn, + 'branch_newer_direct_sibling_001', + title='Qwen Routing Audit #3 stale sibling', + started_at=t0 + 150, + parent_session_id='branch_root_001', + messages=2, + ) + _insert_agent_session_row( + conn, + 'branch_fresh_tip_001', + title='Qwen Routing Audit #4 freshest tip', + started_at=t0 + 500, + parent_session_id='branch_old_mid_001', + messages=2, + ) + _insert_agent_session_row( + conn, + 'branch_empty_stale_tip_001', + title='Qwen Routing Audit #5 empty stale tip', + started_at=t0 + 700, + parent_session_id='branch_old_mid_001', + messages=0, + ) + conn.execute( + "UPDATE sessions SET message_count = 3 WHERE id = ?", + ('branch_empty_stale_tip_001',), + ) + conn.commit() + + post('/api/settings', {'show_cli_sessions': True}) + data, status = get('/api/sessions') + assert status == 200 + ids = {s.get('session_id') for s in data.get('sessions', [])} + tip = next((s for s in data.get('sessions', []) if s.get('session_id') == 'branch_fresh_tip_001'), None) + + assert 'branch_fresh_tip_001' in ids + assert 'branch_newer_direct_sibling_001' not in ids + assert 'branch_empty_stale_tip_001' not in ids + assert tip is not None + assert tip.get('title') == 'Qwen Routing Audit' + assert abs(tip.get('updated_at') - (t0 + 501)) < 0.01 + assert tip.get('_lineage_root_id') == 'branch_root_001' + assert tip.get('_lineage_tip_id') == 'branch_fresh_tip_001' + assert tip.get('_compression_segment_count') == 5 + + from api.agent_sessions import read_importable_agent_session_rows + + rows = read_importable_agent_session_rows(_get_state_db_path(), limit=None) + projected_tip = next((row for row in rows if row.get('id') == 'branch_fresh_tip_001'), None) + assert projected_tip is not None + assert projected_tip.get('_lineage_root_id') == 'branch_root_001' + assert projected_tip.get('_lineage_tip_id') == 'branch_fresh_tip_001' + assert projected_tip.get('_compression_segment_count') == 5 + + from api.agent_sessions import read_session_lineage_metadata + + metadata = read_session_lineage_metadata( + _get_state_db_path(), + {'branch_old_mid_001', 'branch_fresh_tip_001', 'branch_empty_stale_tip_001', 'branch_newer_direct_sibling_001'}, + ) + assert metadata['branch_old_mid_001'].get('_lineage_tip_id') == 'branch_fresh_tip_001' + assert metadata['branch_empty_stale_tip_001'].get('_lineage_tip_id') == 'branch_fresh_tip_001' + assert metadata['branch_newer_direct_sibling_001'].get('_lineage_tip_id') == 'branch_fresh_tip_001' + assert metadata['branch_newer_direct_sibling_001'].get('_compression_segment_count') == 5 + finally: + try: + _remove_test_sessions(conn, *ids_to_remove) + conn.close() + except Exception: + pass + post('/api/settings', {'show_cli_sessions': False}) + + +def test_compression_projection_handles_deep_lineage_iteratively(): + """Very deep compression chains should not depend on Python recursion depth.""" + from api.agent_sessions import _project_agent_session_rows + + rows = [] + previous = None + for idx in range(1100): + sid = f'deep_chain_{idx:04d}' + started_at = float(idx * 2) + rows.append({ + 'id': sid, + 'title': 'Deep Chain' if idx == 0 else f'Deep Chain #{idx + 1}', + 'source': 'cli', + 'started_at': started_at, + 'parent_session_id': previous, + 'ended_at': started_at + 1 if idx < 1099 else None, + 'end_reason': 'compression' if idx < 1099 else None, + 'actual_message_count': 1, + 'actual_user_message_count': 1, + 'message_count': 1, + 'last_activity': started_at + 0.5, + }) + previous = sid + + projected = _project_agent_session_rows(rows) + + assert len(projected) == 1 + assert projected[0]['id'] == 'deep_chain_1099' + assert projected[0]['_lineage_root_id'] == 'deep_chain_0000' + assert projected[0]['_lineage_tip_id'] == 'deep_chain_1099' + assert projected[0]['_compression_segment_count'] == 1100 + + def test_compression_chain_with_empty_latest_tip_falls_back_to_latest_importable_segment(): """Empty latest tips should not make the whole conversation disappear.""" conn = _ensure_state_db() diff --git a/tests/test_inflight_stream_reuse.py b/tests/test_inflight_stream_reuse.py index fe9a72e33e2..1a2888f3af2 100644 --- a/tests/test_inflight_stream_reuse.py +++ b/tests/test_inflight_stream_reuse.py @@ -869,7 +869,7 @@ def test_load_session_restores_worklog_shell_before_reattach_replay(): clear_pos = fallback_block.find("clearLiveToolCards();") shell_pos = fallback_block.find("ensureLiveWorklogShell()") legacy_pos = fallback_block.find("else appendThinking();") - replay_pos = fallback_block.find("appendLiveToolCard(tc);") + replay_pos = fallback_block.find("replayPersistedLiveToolCards();") invariant_pos = fallback_block.find("!liveTurn||!liveTurn.querySelector") assert clear_pos != -1, "fallback must clear stale live tool DOM first" assert shell_pos != -1, "fallback must restore a quiet live Worklog shell" @@ -880,6 +880,57 @@ def test_load_session_restores_worklog_shell_before_reattach_replay(): assert replay_pos < invariant_pos +def test_restore_succeeded_reconnect_replays_tool_cards(): + """When reconnect replay succeeds in restoring the live turn HTML, tool cards + are still repainted from the persisted live-call list instead of waiting for a + future SSE event to reintroduce them.""" + body = _function_body(SESSIONS_JS, "loadSession") + replay_fn = body.find("const replayPersistedLiveToolCards=(opts)=>{") + reattach_pos = body.find("if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function')") + restore_pos = body.find("if(typeof restoreLiveTurnHtmlForSession==='function'){", reattach_pos if reattach_pos != -1 else 0) + fallback_pos = body.find("if(!restoredLiveTurn){", restore_pos) + restore_replay_pos = body.find("if(restoredLiveTurn&&didReconnect){", restore_pos) + restore_replay_block = body[restore_replay_pos:fallback_pos] + helper_replay_call = restore_replay_block.find("replayPersistedLiveToolCards({skipUnkeyedRestoredDuplicates:true});") + assert reattach_pos != -1, "loadSession must keep the reconnect reattach branch" + assert replay_fn != -1, "loadSession should extract live tool replay into a helper" + assert restore_pos != -1, "loadSession must still execute restoreLiveTurnHtmlForSession" + assert reattach_pos > replay_fn, "live-tool replay helper must be defined before reattach branch" + assert restore_pos > reattach_pos, "restore/fallback branch should be after reattach handling in INFLIGHT flow" + assert restore_replay_pos != -1, "restored live turns must explicitly replay tools on reconnect" + assert helper_replay_call != -1, "replay helper must be executed so reconnect can repopulate tool cards" + assert replay_fn < restore_replay_pos < fallback_pos, "restore+reconnect replay should run before fallback" + assert restore_replay_block.strip().startswith("if(restoredLiveTurn&&didReconnect){") + assert ( + "if(restoredLiveTurn&&didReconnect){" + "replayPersistedLiveToolCards({skipUnkeyedRestoredDuplicates:true});" + "}" + ) in re.sub(r"\s+", "", restore_replay_block) + + +def test_restore_succeeded_reconnect_skips_unkeyed_restored_tool_duplicates(): + """Restored snapshots can already contain legacy tool rows without live tids. + + Replaying an unkeyed persisted tool over that restored DOM would append a + duplicate, so the restore-success reconnect path should only replay unkeyed + tools when the restored turn has no visible tool rows to preserve. + """ + body = _function_body(SESSIONS_JS, "loadSession") + replay_fn = body.find("const replayPersistedLiveToolCards=(opts)=>{") + restore_replay_pos = body.find("if(restoredLiveTurn&&didReconnect){") + fallback_pos = body.find("if(!restoredLiveTurn){", restore_replay_pos) + assert replay_fn != -1, "loadSession should keep replay options on the helper" + assert "const liveToolReplayId=(tc)=>" in body + assert "tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id" in body + helper_block = body[replay_fn:restore_replay_pos] + assert "skipUnkeyedRestoredDuplicates" in helper_block + assert "restoredLiveTurn.querySelector('.tool-card-row')" in helper_block + assert "hasRestoredLiveToolRows&&!liveToolReplayId(tc)" in helper_block + restore_block = body[restore_replay_pos:fallback_pos] + assert "replayPersistedLiveToolCards({skipUnkeyedRestoredDuplicates:true});" in restore_block + assert "replayPersistedLiveToolCards();" in body[fallback_pos:body.find("loadDir('.')", fallback_pos)] + + def test_merge_inflight_tail_preserves_all_segmented_live_progress(): """The reattach merge must keep every projected live progress segment. diff --git a/tests/test_issue1796_error_toasts.py b/tests/test_issue1796_error_toasts.py index 0a0e4997f7a..d8ed25cc4aa 100644 --- a/tests/test_issue1796_error_toasts.py +++ b/tests/test_issue1796_error_toasts.py @@ -34,5 +34,13 @@ def test_toast_dismissal_pauses_on_hover_and_keyboard_focus(): assert "onmouseleave=()=>setToastDismissTimer(el,duration)" in UI_JS assert "onfocusin=()=>clearToastDismissTimer(el)" in UI_JS assert "onfocusout=()=>setToastDismissTimer(el,duration)" in UI_JS - assert ".toast{pointer-events:auto" in STYLE_CSS + # A *visible* toast must remain interactive so hover/focus can pause the + # dismiss timer. Interactivity lives on `.toast.show` (see #3735): the hidden + # base `.toast` is pointer-events:none so its invisible padding can't eat + # taps on controls underneath it (mobile profile buttons), and it becomes + # pointer-events:auto only once shown. + assert ".toast.show{" in STYLE_CSS + show_rule = STYLE_CSS[STYLE_CSS.index(".toast.show{"):STYLE_CSS.index("}", STYLE_CSS.index(".toast.show{"))] + assert "pointer-events:auto" in show_rule + assert ".toast{pointer-events:none" in STYLE_CSS # hidden toast must not intercept clicks (#3735) assert ".toast-copy" in STYLE_CSS diff --git a/tests/test_issue1896_context_length_fallback_args.py b/tests/test_issue1896_context_length_fallback_args.py index d22ba1e5be6..2ea48c4e2e4 100644 --- a/tests/test_issue1896_context_length_fallback_args.py +++ b/tests/test_issue1896_context_length_fallback_args.py @@ -30,7 +30,7 @@ # Both fallback callsites must pass these kwargs into get_model_context_length. _REQUIRED_KWARGS = ( "config_context_length=_cfg_ctx_len", - "provider=resolved_provider or ''", + "provider=_cfg_provider", "custom_providers=_cfg_custom_providers", ) @@ -120,11 +120,11 @@ def test_both_callsites_pass_config_context_length(): def test_both_callsites_pass_provider(): - """Both callsites must pass `provider=resolved_provider or ''`.""" + """Both callsites must pass the effective provider into the resolver.""" blocks = _both_callsites() for i, block in enumerate(blocks): - assert "provider=resolved_provider" in block, ( - f"Callsite #{i+1} is missing `provider=resolved_provider...`. " + assert "provider=_cfg_provider" in block, ( + f"Callsite #{i+1} is missing `provider=_cfg_provider`. " f"Provider is needed for the registry lookup step (models.dev " f"provider-aware lookup). See #1896.\n\nBlock:\n{block}" ) @@ -170,13 +170,15 @@ def test_cfg_custom_providers_resolved_from_cfg_dict(): """The kwargs source must be the per-profile config (`_cfg`), not a module-level snapshot — otherwise profile switches with different custom_providers wouldn't take effect.""" - # Look for the resolution pattern. - assert "_cfg.get('custom_providers')" in STREAMING_PY, ( - "_cfg_custom_providers must be sourced from `_cfg.get('custom_providers')` " + # The parsing now lives in the shared route helper so session-load, + # session-save, and SSE fallbacks cannot drift. + assert "_context_length_lookup_inputs_for_model(" in STREAMING_PY + assert 'cfg.get("custom_providers")' in ROUTES_PY, ( + "_cfg_custom_providers must be sourced from `cfg.get('custom_providers')` " "(per-profile config) so profile-scoped custom_providers entries work." ) - assert "_cfg.get('model', {})" in STREAMING_PY, ( - "_cfg_ctx_len must be sourced from `_cfg.get('model', {}).get('context_length')` " + assert 'cfg.get("model", {})' in ROUTES_PY, ( + "_cfg_ctx_len must be sourced from `cfg.get('model', {}).get('context_length')` " "(per-profile config) so profile-scoped model.context_length overrides work." ) @@ -218,7 +220,7 @@ def test_routes_session_load_fallback_passes_config_overrides(): "session-load fallback in api/routes.py must pass config_context_length= " "so user-set model.context_length wins over the 256K default. See #1896." ) - assert "provider=provider or" in helper, ( + assert "provider=_ctx_lookup.provider or provider or" in helper, ( "session-load fallback in api/routes.py must pass provider= " "so the registry lookup is provider-aware. See #1896." ) diff --git a/tests/test_issue3717_context_length_provider_overrides.py b/tests/test_issue3717_context_length_provider_overrides.py new file mode 100644 index 00000000000..39e7ab185de --- /dev/null +++ b/tests/test_issue3717_context_length_provider_overrides.py @@ -0,0 +1,198 @@ +"""Regression coverage for #3717 provider-scoped context-length overrides.""" + +import sys +import types +from pathlib import Path + + +REPO = Path(__file__).resolve().parent.parent +STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8") +ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8") + + +def _install_fake_context_resolver(monkeypatch): + calls = [] + mod = types.ModuleType("agent.model_metadata") + + def _fake( + model, + base_url="", + *args, + config_context_length=None, + provider="", + custom_providers=None, + **kwargs, + ): + calls.append( + { + "model": model, + "base_url": base_url, + "config_context_length": config_context_length, + "provider": provider, + "custom_providers": custom_providers, + } + ) + return config_context_length or 256000 + + mod.get_model_context_length = _fake + if "agent" not in sys.modules: + agent_pkg = types.ModuleType("agent") + agent_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "agent", agent_pkg) + monkeypatch.setitem(sys.modules, "agent.model_metadata", mod) + return calls + + +def test_route_resolver_uses_provider_model_context_length(monkeypatch): + import api.config as config + import api.routes as routes + + calls = _install_fake_context_resolver(monkeypatch) + monkeypatch.setattr( + config, + "get_config", + lambda *a, **k: { + "model": { + "default": "default-model", + "context_length": 123456, + }, + "providers": { + "openrouter": { + "base_url": "https://openrouter.example/v1", + "models": { + "provider-model": {"context_length": 777000}, + }, + }, + }, + "custom_providers": [], + }, + ) + + result = routes._resolve_context_length_for_session_model( + "provider-model", + "openrouter", + ) + + assert result == 777000 + assert calls[-1]["config_context_length"] == 777000 + assert calls[-1]["base_url"] == "https://openrouter.example/v1" + assert calls[-1]["provider"] == "openrouter" + + +def test_route_resolver_uses_provider_model_context_length_without_base_url(monkeypatch): + import api.config as config + import api.routes as routes + + calls = _install_fake_context_resolver(monkeypatch) + monkeypatch.setattr( + config, + "get_config", + lambda *a, **k: { + "model": { + "default": "default-model", + "context_length": 123456, + }, + "providers": { + "anthropic": { + "models": { + "provider-model": {"context_length": 777000}, + }, + }, + }, + "custom_providers": [], + }, + ) + + result = routes._resolve_context_length_for_session_model( + "provider-model", + "anthropic", + ) + + assert result == 777000 + assert calls[-1]["config_context_length"] == 777000 + assert calls[-1]["base_url"] == "" + assert calls[-1]["provider"] == "anthropic" + + +def test_route_resolver_uses_named_custom_provider_base_url(monkeypatch): + import api.config as config + import api.routes as routes + + custom_providers = [ + { + "name": "ZenMux", + "base_url": "https://zenmux.example/v1", + "models": { + "custom-model": {"context_length": "888000"}, + }, + } + ] + calls = _install_fake_context_resolver(monkeypatch) + monkeypatch.setattr( + config, + "get_config", + lambda *a, **k: { + "model": { + "default": "default-model", + "context_length": 123456, + }, + "custom_providers": custom_providers, + }, + ) + + result = routes._resolve_context_length_for_session_model( + "custom-model", + "custom:zenmux", + ) + + assert result == 888000 + assert calls[-1]["config_context_length"] == 888000 + assert calls[-1]["base_url"] == "https://zenmux.example/v1" + assert calls[-1]["provider"] == "custom:zenmux" + assert calls[-1]["custom_providers"] is custom_providers + + +def test_global_context_length_remains_default_model_only(monkeypatch): + import api.config as config + import api.routes as routes + + calls = _install_fake_context_resolver(monkeypatch) + monkeypatch.setattr( + config, + "get_config", + lambda *a, **k: { + "model": { + "default": "default-model", + "context_length": 123456, + }, + "providers": { + "openai": { + "base_url": "https://openai.example/v1", + "models": {}, + }, + }, + }, + ) + + result = routes._resolve_context_length_for_session_model("other-model", "openai") + + assert result == 256000 + assert calls[-1]["config_context_length"] is None + assert calls[-1]["base_url"] == "https://openai.example/v1" + + +def test_streaming_fallbacks_use_shared_provider_context_helper(): + assert STREAMING_PY.count("_context_length_lookup_inputs_for_model(") >= 2 + assert "_cfg_base_url = getattr(agent, 'base_url', '') or resolved_base_url or ''" in STREAMING_PY + assert "base_url=_cfg_base_url" in STREAMING_PY + assert "config_context_length=_cfg_ctx_len" in STREAMING_PY + assert "provider=_cfg_provider" in STREAMING_PY + assert "custom_providers=_cfg_custom_providers" in STREAMING_PY + assert "_cfg_base_url" in STREAMING_PY + + +def test_route_helper_keeps_all_context_length_sources_aligned(): + assert "def _context_length_lookup_inputs_for_model" in ROUTES_PY + assert 'cfg.get("providers", {})' in ROUTES_PY + assert 'cfg.get("custom_providers")' in ROUTES_PY + assert "_model_matches_configured_default" in ROUTES_PY diff --git a/tests/test_issue3718_live_models_custom_probe.py b/tests/test_issue3718_live_models_custom_probe.py new file mode 100644 index 00000000000..8939523b046 --- /dev/null +++ b/tests/test_issue3718_live_models_custom_probe.py @@ -0,0 +1,193 @@ +"""Regression test for #3718 -- /api/models/live skips probe for custom providers. + +When a custom provider (``custom_providers`` entry in config.yaml) has a +``model:`` field but no ``models:`` allowlist, the live endpoint previously +populated ``ids`` from the config entry and then skipped the live ``/v1/models`` +probe (guarded by ``if not ids``). The fix collects config-specified model IDs +in a separate ``_config_ids`` list so the live fetch always runs, and merges +config entries as a fallback after the fetch. +""" +import json +import pathlib +import unittest +from unittest import mock + +REPO = pathlib.Path(__file__).parent.parent +ROUTES_PY = REPO / "api" / "routes.py" + + +class TestLiveModelsCustomProviderProbe(unittest.TestCase): + """Live endpoint must probe the upstream /v1/models even when config has entries.""" + + def test_custom_provider_with_model_field_probes_upstream(self): + """When a custom provider has model: in config, live fetch must still run. + + Before the fix, _custom_provider_model_ids() populated ids=["assistant"], + and the `if not ids` guard prevented the upstream /v1/models probe from + running. The endpoint returned only ["assistant"] instead of the full + upstream catalog. + + We test the fix by simulating the logic directly: config provides + model IDs, the upstream probe returns a different set, and the result + must contain BOTH the live models and the config fallback. + """ + # Simulate config-specified model IDs + _config_ids = ["assistant"] + + # Simulate upstream /v1/models response + live_models = [ + "assistant", + "assistant-pro", + "assistant-zdr", + "gpt-5.5:pt", + "claude-sonnet-4.6:pt", + ] + + # Simulate the merge logic from the fix: + # if ids: merge config entries not in live set + # else: fall back to config-only list + ids = list(live_models) # live fetch succeeded + _live_set = set(ids) + for _cid in _config_ids: + if _cid not in _live_set: + ids.append(_cid) + + # "assistant" from config should not duplicate the live result + self.assertEqual(ids, live_models) + # All live models must be present + for m in live_models: + self.assertIn(m, ids) + + def test_custom_provider_falls_back_to_config_when_probe_fails(self): + """When the upstream probe fails, config entries must be used as fallback.""" + _config_ids = ["assistant", "custom-only-model"] + + # Simulate failed live fetch: ids stays empty + ids = [] + + # Fallback logic from the fix + if not ids: + ids = list(_config_ids) + + self.assertEqual(ids, ["assistant", "custom-only-model"]) + + def test_config_only_model_appended_when_not_in_live_results(self): + """Config-specified models not in the live results must be appended.""" + _config_ids = ["assistant", "local-finetune"] + live_models = ["assistant", "assistant-pro", "gpt-5.5:pt"] + + ids = list(live_models) + _live_set = set(ids) + for _cid in _config_ids: + if _cid not in _live_set: + ids.append(_cid) + + # "assistant" is in both, "local-finetune" is config-only + self.assertIn("local-finetune", ids) + self.assertIn("assistant-pro", ids) + self.assertEqual(ids.count("assistant"), 1, "assistant must not be duplicated") + + def test_live_fetch_guard_no_longer_checks_ids(self): + """The live fetch block must not be guarded by `if not ids`. + + This is a structural regression guard: the fix replaced + `if not ids and (provider == "custom" ...)` with + `if provider == "custom" ...` so the probe always runs regardless + of whether config entries populated ids. + """ + source = ROUTES_PY.read_text(encoding="utf-8") + + # Find the "Always try live fetch" comment (added by the fix) + marker = "Always try live fetch for custom providers" + self.assertIn(marker, source, ( + "routes.py must contain the 'Always try live fetch' comment (#3718)" + )) + + # Extract the block between the marker and the next major section + marker_pos = source.find(marker) + next_section = source.find("OpenAI-compat live fetch fallback", marker_pos) + self.assertNotEqual(next_section, -1, "could not find next section marker") + block = source[marker_pos:next_section] + + # The old `if not ids and` guard must NOT appear in this block + self.assertNotIn("if not ids and", block, ( + "Live fetch for custom providers must not be guarded by 'if not ids' (#3718)" + )) + + def test_mocked_live_fetch_returns_full_catalog_plus_config_entry(self): + """Integration test: mock urlopen and exercise the real fetch/parse/merge path. + + This test patches urllib.request.urlopen to return a fake /v1/models + response, then runs the same fetch+merge logic that the live handler + uses, verifying that config entries are merged and live models are + included. + """ + fake_models = {"data": [ + {"id": "assistant"}, + {"id": "assistant-pro"}, + {"id": "gpt-5.5:pt"}, + ]} + fake_body = json.dumps(fake_models).encode("utf-8") + fake_resp = mock.MagicMock() + fake_resp.read.return_value = fake_body + fake_resp.__enter__ = mock.MagicMock(return_value=fake_resp) + fake_resp.__exit__ = mock.MagicMock(return_value=False) + + _config_ids = ["assistant", "local-only"] + + with mock.patch("urllib.request.urlopen", return_value=fake_resp): + # Replicate the fetch+parse logic from routes.py + import urllib.request + ids = [] + _req = urllib.request.Request( + "http://localhost:4000/v1/models", + headers={"Authorization": "Bearer test-key"}, + ) + with urllib.request.urlopen(_req, timeout=5) as _resp: + _body = json.loads(_resp.read()) + + if isinstance(_body, dict): + _data = _body.get("data", []) + if isinstance(_data, list): + ids = [m.get("id", "") for m in _data if m.get("id")] + + # Apply the merge logic from the fix + if ids: + _live_set = set(ids) + for _cid in _config_ids: + if _cid not in _live_set: + ids.append(_cid) + else: + ids = list(_config_ids) + + # Live models must all be present + self.assertIn("assistant", ids) + self.assertIn("assistant-pro", ids) + self.assertIn("gpt-5.5:pt", ids) + # Config-only entry must be appended + self.assertIn("local-only", ids) + # No duplicates + self.assertEqual(ids.count("assistant"), 1) + + def test_timeout_uses_config_constant(self): + """The live fetch must use CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS, not a hardcoded value.""" + source = ROUTES_PY.read_text(encoding="utf-8") + + # Find the custom-provider live fetch block + marker = "Always try live fetch for custom providers" + marker_pos = source.find(marker) + next_section = source.find("OpenAI-compat live fetch fallback", marker_pos) + block = source[marker_pos:next_section] + + # Must use the constant, not a bare number + self.assertIn("CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS", block, ( + "Live fetch timeout must use CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS " + "instead of a hardcoded value (#3718 review feedback)" + )) + self.assertNotIn("timeout=8", block, ( + "Live fetch must not use hardcoded timeout=8 (#3718 review feedback)" + )) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_merge_key_tool_calls.py b/tests/test_merge_key_tool_calls.py index 40ebd08343b..40c5b4ac357 100644 --- a/tests/test_merge_key_tool_calls.py +++ b/tests/test_merge_key_tool_calls.py @@ -10,6 +10,7 @@ """ from __future__ import annotations +from api import models from api.models import ( _matching_visible_duplicate, _session_message_dedup_key, @@ -130,3 +131,43 @@ def test_no_tool_calls_still_deduped(self): msg = {"role": "assistant", "content": "hello", "timestamp": 1000} result = merge_session_messages_append_only([msg], [msg]) assert len(result) == 1 + + +# ── large-payload duplicate matching performance ──────────────────────────── + + +class TestVisibleDuplicateLargePayloadPerformance: + def test_large_nonmatching_payload_skips_loose_normalizer(self, monkeypatch): + """Giant tool/log payloads must not be regex-tokenized for fuzzy matching. + + Exact visible-key equality is checked before this path. For non-exact + multi-hundred-KB payloads, fuzzy substring/token matching is too costly + for the /api/session hot path and low-value for deduplication. + """ + def fail_if_called(_content): + raise AssertionError("large payloads should not hit loose normalizer") + + monkeypatch.setattr(models, "_loose_session_message_content", fail_if_called) + + large_state = ("state output\n" * 25_000).strip() + large_sidecar = ("sidecar output\n" * 25_000).strip() + visible_key = ("assistant", large_state, "") + sidecar_key = ("assistant", large_sidecar, "") + + assert _matching_visible_duplicate(visible_key, {sidecar_key}) is None + + def test_small_nonmatching_payload_keeps_loose_matching(self, monkeypatch): + """The large-payload guard must not disable legacy fuzzy matching.""" + calls = [] + + def counted_loose(content): + calls.append(content) + return " ".join(str(content).lower().replace(",", "").replace("!", "").split()) + + monkeypatch.setattr(models, "_loose_session_message_content", counted_loose) + + visible_key = ("assistant", "hello world", "") + sidecar_key = ("assistant", "HELLO, WORLD!!", "") + + assert _matching_visible_duplicate(visible_key, {sidecar_key}) == sidecar_key + assert len(calls) == 2 diff --git a/tests/test_onboarding_network.py b/tests/test_onboarding_network.py index 25352122026..230205fb1b3 100644 --- a/tests/test_onboarding_network.py +++ b/tests/test_onboarding_network.py @@ -30,7 +30,18 @@ # Unit tests — directly test the IP-resolution + guard logic in routes.py # without needing a live server. We replicate the logic to keep tests fast # and independent of server startup. -# --------------------------------------------------------------------------- +# +# ⚠️ STALE CONTRACT (see #3765): this mirror encodes the PRE-#3758 model where an +# unauthenticated `X-Forwarded-For` / `X-Real-IP` was trusted to establish +# locality (`_xff or _xri or raw_ip`). As of #3758 (v0.51.307) the real gate +# `api.routes._onboarding_request_is_local()` IGNORES forwarded headers unless +# `HERMES_WEBUI_TRUST_FORWARDED_FOR=1`, and only loopback counts as local when an +# untrusted forwarded header is present. The authoritative trust-matrix tests now +# live in `tests/test_security_review_fixes.py`. This mirror + its +# `TestOnboardingIPLogic` cases are retained only as historical fast-path unit +# coverage of the IP-parsing shape; DO NOT treat them as the current security +# contract. (Migrating them to delegate to the real helper is tracked as +# follow-up test debt, intentionally out of scope for the #3765 hotfix.) def _is_local_from_handler( raw_ip: str, @@ -40,9 +51,9 @@ def _is_local_from_handler( open_env: bool = False, ) -> bool | str: """ - Mirror of the onboarding IP check in api/routes.py. - Returns True if the request would be allowed, False if blocked, - or the error message string if blocked. + Mirror of the LEGACY (pre-#3758) onboarding IP check. See the stale-contract + note above — the live gate no longer trusts forwarded headers by default. + Returns True if the request would be allowed, False if blocked. """ import ipaddress diff --git a/tests/test_pr1370_lineage_metadata_perf_and_orphan.py b/tests/test_pr1370_lineage_metadata_perf_and_orphan.py index cdab67b41be..02f999a6882 100644 --- a/tests/test_pr1370_lineage_metadata_perf_and_orphan.py +++ b/tests/test_pr1370_lineage_metadata_perf_and_orphan.py @@ -132,6 +132,42 @@ def test_orphan_parent_reference_not_exposed_in_metadata(tmp_path): ) +def test_old_schema_without_source_or_messages_table_keeps_lineage(tmp_path): + """Old state.db schemas may lack optional source/message tables but still carry lineage.""" + from api.agent_sessions import read_session_lineage_metadata + + db = tmp_path / "state.db" + conn = sqlite3.connect(str(db)) + conn.executescript(""" + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT, + started_at REAL NOT NULL, + parent_session_id TEXT, + ended_at REAL, + end_reason TEXT + ); + CREATE INDEX idx_sessions_parent ON sessions(parent_session_id); + """) + t0 = time.time() - 100 + conn.execute( + "INSERT INTO sessions (id, title, started_at, ended_at, end_reason) VALUES (?, ?, ?, ?, ?)", + ("old_root", "old_root", t0, t0 + 5, "compression"), + ) + conn.execute( + "INSERT INTO sessions (id, title, started_at, parent_session_id) VALUES (?, ?, ?, ?)", + ("old_tip", "old_tip", t0 + 6, "old_root"), + ) + conn.commit() + conn.close() + + result = read_session_lineage_metadata(db, ["old_tip"]) + + assert result["old_tip"]["parent_session_id"] == "old_root" + assert result["old_tip"]["_lineage_root_id"] == "old_root" + assert result["old_tip"]["_compression_segment_count"] == 2 + + def test_cycle_in_parent_chain_terminates(tmp_path): """Pathological data with a parent cycle (A→B→A) must not infinite-loop.""" from api.agent_sessions import read_session_lineage_metadata @@ -282,3 +318,133 @@ def test_non_compression_parent_does_not_extend_lineage(tmp_path): # _lineage_root_id should NOT be set — chain doesn't span the boundary assert "_lineage_root_id" not in entry assert "_compression_segment_count" not in entry + + + +# ── #3751 backward-compat: messages-table schema variants must not collapse +# the lineage metadata (regression for the gate finding on stage-a2/v0.51.306) ── + +def _make_db_with_messages(path, *, timestamp_type): + """Build a state.db with a compression lineage and a messages table whose + timestamp column is REAL, absent, or TEXT (ISO-8601).""" + conn = sqlite3.connect(str(path)) + conn.executescript(""" + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + title TEXT, + model TEXT, + started_at REAL NOT NULL, + message_count INTEGER DEFAULT 0, + parent_session_id TEXT, + ended_at REAL, + end_reason TEXT + ); + CREATE INDEX idx_sessions_parent ON sessions(parent_session_id); + """) + if timestamp_type == "absent": + conn.execute("CREATE TABLE messages (session_id TEXT, role TEXT, content TEXT)") + else: + conn.execute(f"CREATE TABLE messages (session_id TEXT, role TEXT, content TEXT, timestamp {timestamp_type})") + # Compression chain: root --(compression)--> tip + now = time.time() + conn.execute( + "INSERT INTO sessions (id, source, title, model, started_at, parent_session_id, ended_at, end_reason) " + "VALUES ('root', 'webui', 'root', 'openai/gpt-5', ?, NULL, ?, 'compression')", + (now - 100, now - 50), + ) + conn.execute( + "INSERT INTO sessions (id, source, title, model, started_at, parent_session_id, ended_at, end_reason) " + "VALUES ('tip', 'webui', 'tip', 'openai/gpt-5', ?, 'root', NULL, NULL)", + (now - 40,), + ) + if timestamp_type == "absent": + conn.execute("INSERT INTO messages (session_id, role, content) VALUES ('tip', 'user', 'hi')") + elif timestamp_type == "TEXT": + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('tip', 'user', 'hi', '2026-06-06T12:00:00Z')" + ) + else: # REAL + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('tip', 'user', 'hi', ?)", + (now - 35,), + ) + conn.commit() + conn.close() + + +@pytest.mark.parametrize("timestamp_type", ["REAL", "absent", "TEXT"]) +def test_lineage_metadata_survives_messages_timestamp_schema_variants(tmp_path, timestamp_type): + """The branchy-lineage tip resolver pulls per-session message stats from the + messages table. Older/minimal state.db schemas can have a messages table with + NO timestamp column, or a non-numeric (ISO-8601 text) timestamp. Neither may + raise out of the DB block and collapse ALL lineage metadata to {} — which is + a silent regression vs. the prior behavior that returned metadata. + """ + from api.agent_sessions import read_session_lineage_metadata + + db = tmp_path / "state.db" + _make_db_with_messages(db, timestamp_type=timestamp_type) + + result = read_session_lineage_metadata(db, ["tip"]) + entry = result.get("tip", {}) + # The compression lineage must still be reported (not silently dropped). + assert entry.get("_lineage_root_id") == "root", ( + f"lineage metadata collapsed for messages.timestamp={timestamp_type}: {result!r}" + ) + # And the canonical tip must resolve to the messageful continuation. + assert entry.get("_lineage_tip_id") == "tip" + + +def test_importable_rows_survive_text_timestamp_in_messages(tmp_path): + """read_importable_agent_session_rows() builds a per-session MAX(timestamp) + and the compression_tip() DFS scores tips by last_activity. An older/ + non-standard messages.timestamp stored as ISO-8601 TEXT must not raise a + TypeError out of the projection (get_cli_sessions() would swallow it and + return [] — silently hiding ALL imported agent rows). Sibling-path guard to + the read_session_lineage_metadata fix. + """ + from api.agent_sessions import read_importable_agent_session_rows + + db = tmp_path / "state.db" + conn = sqlite3.connect(str(db)) + conn.executescript(""" + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + title TEXT, + model TEXT, + started_at REAL NOT NULL, + message_count INTEGER DEFAULT 0, + parent_session_id TEXT, + ended_at REAL, + end_reason TEXT + ); + CREATE INDEX idx_sessions_parent ON sessions(parent_session_id); + CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, timestamp TEXT); + """) + now = time.time() + # compression chain root --(compression)--> tip, tip has a messageful row + conn.execute( + "INSERT INTO sessions (id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) " + "VALUES ('root', 'cli', 'root', 'openai/gpt-5', ?, 0, NULL, ?, 'compression')", + (now - 100, now - 50), + ) + conn.execute( + "INSERT INTO sessions (id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) " + "VALUES ('tip', 'cli', 'tip', 'openai/gpt-5', ?, 1, 'root', NULL, NULL)", + (now - 40,), + ) + # ISO-8601 TEXT timestamp — the value MAX() would return as a string + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('tip', 'user', 'hi', '2026-06-06T12:00:00Z')" + ) + conn.commit() + conn.close() + + # Must NOT raise; must surface the imported chain (cli source). + rows = read_importable_agent_session_rows(db, limit=None, exclude_sources=None) + ids = {r["id"] for r in rows} + assert ids, "import projection returned no rows on a TEXT messages.timestamp (would hide all CLI sessions)" + # the chain collapses to its tip + assert "tip" in ids diff --git a/tests/test_project_chip_ui.py b/tests/test_project_chip_ui.py index 5a08b672fe2..f1cab42ce30 100644 --- a/tests/test_project_chip_ui.py +++ b/tests/test_project_chip_ui.py @@ -166,3 +166,66 @@ def test_create_calls_resize_helper(self): assert "addEventListener('input'" in body, ( "_startProjectCreate must wire input events to _resizeProjectInput" ) + + +class TestProjectChipLongPressTouch: + """Mobile long-press to open the project context menu (#3760). + + Project chips were deletable only via the right-click context menu, which has + no touch equivalent — so mobile users could never remove a project. A 500ms + long-press now opens the same menu. + """ + + def _chip_touch_block(self): + # The chip touch handlers live just after the oncontextmenu wiring in the + # project-chip render loop. + idx = SESSIONS_JS.find("Touch long-press") + assert idx != -1, "project-chip long-press touch block not found" + return SESSIONS_JS[idx: idx + 2300] + + def test_long_press_opens_project_context_menu(self): + block = self._chip_touch_block() + assert "addEventListener('touchstart'" in block + assert "setTimeout(" in block and "},500);" in block + assert "_showProjectContextMenu(" in block + # visual feedback + scroll-drift cancel, mirroring the session-item pattern + assert "long-pressing" in block + assert "addEventListener('touchmove'" in block + assert ">10" in block # >10px drift cancels the press + + def test_long_press_suppresses_synthetic_click_and_filter_tap(self): + block = self._chip_touch_block() + # touchend must be non-passive so it can preventDefault the synthetic click + assert "addEventListener('touchend'" in block + assert "{passive:false}" in block + assert "e.preventDefault();e.stopPropagation();" in block + # the long-press handler cancels the pending single-tap filter timer + assert "clearTimeout(_pClickTimer)" in block + + def test_touchstart_clears_inflight_timer_before_scheduling(self): + """Regression: a second finger / stray touchstart must not orphan the + prior timer (which would then fire the menu after the gesture was + cancelled). touchstart clears any in-flight _lpTimer before scheduling, + and the timer body bails if the gesture was already consumed. + """ + block = self._chip_touch_block() + # clear-before-schedule at the top of touchstart + assert "if(_lpTimer){clearTimeout(_lpTimer);_lpTimer=null;}" in block, ( + "touchstart must clear any in-flight long-press timer before scheduling " + "a new one (orphaned-timer fix)" + ) + # stale-fire guard inside the timer body + assert "if(_lpHandled) return;" in block, ( + "the long-press timer body must no-op if the gesture was already consumed" + ) + + def test_long_pressing_style_feedback_present(self): + assert ".project-chip.long-pressing" in STYLE_CSS + # Target the base .project-chip rule (the one carrying the layout props), + # not an unrelated theme override of the same selector. + base_idx = STYLE_CSS.find(".project-chip{font-size") + assert base_idx != -1, "base .project-chip rule not found" + chip_rule = STYLE_CSS[base_idx: STYLE_CSS.find("}", base_idx) + 1] + # touch tuning so the native callout/selection doesn't compete with the gesture + assert "touch-action:manipulation" in chip_rule + assert "user-select:none" in chip_rule diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 890413ba5fe..7123eafa29a 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -760,12 +760,12 @@ def test_loadSession_inflight_sets_active_stream_before_replaying_live_tool_card assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession" inflight_block = src[inflight_idx:inflight_idx+4200] active_pos = inflight_block.find("S.activeStreamId=activeStreamId;") - replay_pos = inflight_block.find("appendLiveToolCard(tc);") + replay_pos = inflight_block.find("const replayPersistedLiveToolCards=(opts)=>{") attach_pos = inflight_block.find("attachLiveStream(sid, activeStreamId") assert active_pos >= 0, "loadSession INFLIGHT branch must restore S.activeStreamId" assert replay_pos >= 0, "loadSession INFLIGHT branch must replay persisted live tool cards" assert active_pos < replay_pos, \ - "S.activeStreamId must be restored before appendLiveToolCard() replays persisted tools" + "S.activeStreamId must be restored before replaying persisted tools" assert attach_pos < 0 or active_pos < attach_pos, \ "S.activeStreamId should also be restored before SSE reattach can deliver more tool events" diff --git a/tests/test_remote_terminal_workspace.py b/tests/test_remote_terminal_workspace.py index 04155f87049..ecd0ff449f3 100644 --- a/tests/test_remote_terminal_workspace.py +++ b/tests/test_remote_terminal_workspace.py @@ -55,3 +55,14 @@ def test_remote_terminal_workspace_paths_outside_cwd_still_reject(monkeypatch): with pytest.raises(ValueError, match="Path does not exist"): workspace.resolve_trusted_workspace("/Users/other/projects/demo") + + +@pytest.mark.parametrize("workspace_path", ["/etc", "/etc/ssh"]) +def test_remote_terminal_workspace_system_roots_still_reject(monkeypatch, workspace_path): + monkeypatch.setattr(api_config, "get_config", lambda: _remote_config(terminal={"backend": "ssh", "cwd": "/etc"})) + + with pytest.raises(ValueError, match="Path points to a system directory"): + workspace.validate_workspace_to_add(workspace_path) + + with pytest.raises(ValueError, match="Path points to a system directory"): + workspace.resolve_trusted_workspace(workspace_path) diff --git a/tests/test_security_review_fixes.py b/tests/test_security_review_fixes.py new file mode 100644 index 00000000000..0ac189e80d9 --- /dev/null +++ b/tests/test_security_review_fixes.py @@ -0,0 +1,223 @@ +import io +from types import SimpleNamespace +from urllib.parse import urlsplit +from pathlib import Path + + +class _Headers(dict): + def get(self, key, default=None): + for k, v in self.items(): + if k.lower() == key.lower(): + return v + return default + + +class _Handler: + def __init__(self, *, client_ip="8.8.8.8", headers=None, body=b"{}"): + self.client_address = (client_ip, 12345) + self.headers = _Headers(headers or {}) + self.rfile = io.BytesIO(body) + self.wfile = io.BytesIO() + self.status = None + self.sent_headers = [] + + def send_response(self, code): + self.status = code + + def send_header(self, key, value): + self.sent_headers.append((key, value)) + + def end_headers(self): + pass + + +def test_onboarding_local_gate_ignores_forwarded_ip_unless_trusted(monkeypatch): + from api import routes + + monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) + handler = _Handler( + client_ip="8.8.8.8", + headers={"X-Forwarded-For": "127.0.0.1", "X-Real-IP": "10.0.0.2"}, + ) + + assert routes._onboarding_request_is_local(handler) is False + + +def test_onboarding_local_gate_uses_forwarded_ip_when_explicitly_trusted(monkeypatch): + from api import routes + + monkeypatch.setenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", "1") + handler = _Handler( + client_ip="8.8.8.8", + headers={"X-Forwarded-For": "10.0.0.2", "X-Real-IP": "203.0.113.11"}, + ) + + assert routes._onboarding_request_is_local(handler) is True + + +def test_onboarding_trusted_forwarded_for_uses_proxy_appended_rightmost_ip(monkeypatch): + from api import routes + + monkeypatch.setenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", "1") + handler = _Handler( + client_ip="10.0.0.10", + headers={"X-Forwarded-For": "127.0.0.1, 8.8.8.8"}, + ) + + assert routes._onboarding_request_is_local(handler) is False + + +def test_docker_env_log_obfuscates_password_and_secret_names(): + src = Path("docker_init.bash").read_text(encoding="utf-8") + line = next(l for l in src.splitlines() if l.startswith("export ENV_OBFUSCATE_PART=")) + + assert "PASSWORD" in line + assert "SECRET" in line + assert "TOKEN" in line + assert "API" in line + assert "KEY" in line + + +def test_get_update_check_returns_cache_without_fetch(monkeypatch): + from api import routes, updates + + monkeypatch.setattr(routes, "load_settings", lambda: {"check_for_updates": True}) + monkeypatch.setattr(updates, "cached_update_status", lambda include_agent=True: {"checked_at": 123, "webui": None, "agent": None, "include_agent": include_agent}) + monkeypatch.setattr(updates, "check_for_updates", lambda *a, **k: (_ for _ in ()).throw(AssertionError("GET must not fetch"))) + + handler = _Handler(client_ip="127.0.0.1") + routes.handle_get(handler, urlsplit("/api/updates/check?force=1")) + assert handler.status == 200 + + +def test_cached_update_status_does_not_drop_agent_info_when_reenabled(monkeypatch): + from api import updates + + cached_agent = {"name": "agent", "behind": 2} + monkeypatch.setattr( + updates, + "_update_cache", + { + "webui": {"name": "webui", "behind": 0}, + "agent": cached_agent, + "checked_at": 123, + "include_agent": False, + }, + ) + + result = updates.cached_update_status(include_agent=True) + + assert result["agent"] == cached_agent + + +def test_post_update_check_performs_forced_fetch(monkeypatch): + from api import routes + + calls = [] + monkeypatch.setattr(routes, "load_settings", lambda: {"check_for_updates": True}) + monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) + + def fake_check(*, force=False, include_agent=True): + calls.append((force, include_agent)) + return {"checked_at": 456, "webui": None, "agent": None} + + monkeypatch.setattr("api.updates.check_for_updates", fake_check) + body = b'{"force": true}' + handler = _Handler(client_ip="127.0.0.1", body=body, headers={"Content-Length": str(len(body))}) + routes.handle_post(handler, SimpleNamespace(path="/api/updates/check", query="")) + assert handler.status == 200 + assert calls == [(True, True)] + + +def test_onboarding_untrusted_forwarded_header_denies_lan_proxy_socket(monkeypatch): + """Reverse-proxy regression (release-gate CORE fix): when forwarded headers + are present but HERMES_WEBUI_TRUST_FORWARDED_FOR is NOT set, the spoofable + header is ignored and locality is judged by the raw socket — but a PRIVATE/LAN + raw socket (a separate proxy box that could be forwarding an arbitrary public + client) is NOT treated as local. A loopback raw socket is still genuine + same-host and remains allowed (a remote attacker cannot forge a 127.0.0.1 TCP + source). Operators with a LAN proxy must set HERMES_WEBUI_TRUST_FORWARDED_FOR=1. + """ + from api import routes + + monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) + + # LAN proxy box (private raw socket) forwarding a public client → DENY + handler = _Handler(client_ip="10.0.0.5", headers={"X-Real-IP": "203.0.113.7"}) + assert routes._onboarding_request_is_local(handler) is False + handler2 = _Handler(client_ip="172.20.0.1", headers={"X-Forwarded-For": "8.8.8.8"}) + assert routes._onboarding_request_is_local(handler2) is False + + # Genuine same-host: loopback raw socket is local even if a forwarded header + # is present (the TCP source genuinely came from localhost; unspoofable). + handler3 = _Handler(client_ip="127.0.0.1", headers={"X-Forwarded-For": "8.8.8.8"}) + assert routes._onboarding_request_is_local(handler3) is True + + +def test_onboarding_spoofed_forwarded_header_from_public_socket_denied(monkeypatch): + """The original spoof hole: a public client setting X-Forwarded-For=127.0.0.1 + must NOT bypass the gate. The forwarded header is ignored; the public raw + socket governs → denied. + """ + from api import routes + + monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) + handler = _Handler(client_ip="8.8.8.8", headers={"X-Forwarded-For": "127.0.0.1"}) + assert routes._onboarding_request_is_local(handler) is False + + +def test_onboarding_direct_loopback_without_forwarded_headers_is_local(monkeypatch): + """A genuine direct local client (no proxy headers) is still allowed.""" + from api import routes + + monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) + handler = _Handler(client_ip="127.0.0.1", headers={}) + assert routes._onboarding_request_is_local(handler) is True + + handler_public = _Handler(client_ip="8.8.8.8", headers={}) + assert routes._onboarding_request_is_local(handler_public) is False + + +def test_onboarding_complete_is_gated_against_public_clients(monkeypatch): + """POST /api/onboarding/complete flips the first-run wizard off + (onboarding_completed=True). On a passwordless bind it must be gated on the + same local-network check as setup/oauth/probe, so an unauthenticated public + client can't hide the wizard. (#3765 — sibling-path gap left by #3758.) + """ + from api import routes + + monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) + monkeypatch.setenv("HERMES_WEBUI_ONBOARDING_OPEN", "") + monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) + monkeypatch.setattr("api.auth.is_auth_enabled", lambda: False) + + called = {"n": 0} + def fake_complete(): + called["n"] += 1 + return {"completed": True} + monkeypatch.setattr(routes, "complete_onboarding", fake_complete) + + # Public client (no forwarded headers) → 403, complete_onboarding NOT called + pub = _Handler(client_ip="8.8.8.8", body=b"{}", headers={"Content-Length": "2"}) + routes.handle_post(pub, SimpleNamespace(path="/api/onboarding/complete", query="")) + assert pub.status == 403 + assert called["n"] == 0 + + # Genuine loopback client → allowed + loop = _Handler(client_ip="127.0.0.1", body=b"{}", headers={"Content-Length": "2"}) + routes.handle_post(loop, SimpleNamespace(path="/api/onboarding/complete", query="")) + assert loop.status == 200 + assert called["n"] == 1 + + +def test_onboarding_complete_allowed_when_auth_enabled(monkeypatch): + """With auth configured, onboarding endpoints are reachable normally.""" + from api import routes + + monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) + monkeypatch.setattr("api.auth.is_auth_enabled", lambda: True) + monkeypatch.setattr(routes, "complete_onboarding", lambda: {"completed": True}) + + h = _Handler(client_ip="8.8.8.8", body=b"{}", headers={"Content-Length": "2"}) + routes.handle_post(h, SimpleNamespace(path="/api/onboarding/complete", query="")) + assert h.status == 200 diff --git a/tests/test_terminal_zombie_reaper.py b/tests/test_terminal_zombie_reaper.py new file mode 100644 index 00000000000..aeee461068d --- /dev/null +++ b/tests/test_terminal_zombie_reaper.py @@ -0,0 +1,124 @@ +import os +import sys +import time + +import pytest + +pytestmark = pytest.mark.skipif( + os.name == "nt" + or not sys.platform.startswith("linux") + or not getattr(__import__("api.terminal", fromlist=["_TERMINAL_SUPPORTED"]), "_TERMINAL_SUPPORTED", False), + reason="Linux-only terminal zombie reaper coverage", +) + +import api.terminal as terminal + + +def _wait_until_waitable(pid: int, timeout: float = 2.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = os.waitid(os.P_PID, pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) + if result is not None and result.si_pid == pid: + return + time.sleep(0.01) + raise AssertionError(f"child {pid} did not exit before timeout") + + +def test_reap_terminal_descendants_reaps_exited_child(): + ready_r, ready_w = os.pipe() + pid = os.fork() + if pid == 0: + os.close(ready_r) + os.setpgid(0, 0) + os.write(ready_w, b"1") + os.close(ready_w) + os._exit(0) + + reaped = False + try: + os.close(ready_w) + assert os.read(ready_r, 1) == b"1" + os.close(ready_r) + _wait_until_waitable(pid) + + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + terminal._reap_terminal_descendants(pid) + try: + os.waitid(os.P_PID, pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) + except ChildProcessError: + reaped = True + break + time.sleep(0.01) + + assert reaped, "terminal descendant reaper did not reap the exited child" + finally: + if not reaped: + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + + +def test_close_terminal_reaps_descendants_after_shell_wait(monkeypatch): + class FakeProc: + pid = 987654 + + def __init__(self): + self.wait_calls = [] + self.returncode = None + + def poll(self): + return self.returncode + + def wait(self, timeout=None): + self.wait_calls.append(timeout) + self.returncode = -1 + return self.returncode + + proc = FakeProc() + term = terminal.TerminalSession( + session_id="term-descendant-reap", + workspace="/tmp", + proc=proc, + master_fd=12345, + ) + terminal._TERMINALS["term-descendant-reap"] = term + kills = [] + reaped = [] + + monkeypatch.setattr(terminal.os, "killpg", lambda pid, sig: kills.append((pid, sig))) + monkeypatch.setattr(terminal.os, "close", lambda fd: None) + monkeypatch.setattr(terminal, "_reap_terminal_descendants", lambda pgid: reaped.append(pgid) or 0) + + assert terminal.close_terminal("term-descendant-reap") is True + + assert kills == [(proc.pid, terminal.signal.SIGHUP)] + assert proc.wait_calls == [1.5] + assert reaped == [proc.pid] + + +def test_reap_terminal_descendants_ignores_expected_waitpid_errors(monkeypatch): + calls = [] + + def fake_waitpid(pid, flags): + calls.append((pid, flags)) + raise ChildProcessError() + + monkeypatch.setattr(terminal.os, "waitpid", fake_waitpid) + + assert terminal._reap_terminal_descendants(123) == 0 + assert calls == [(-123, os.WNOHANG)] + + +def test_reap_terminal_descendants_is_bounded(monkeypatch): + calls = [] + + def fake_waitpid(pid, flags): + calls.append((pid, flags)) + return (len(calls), 0) + + monkeypatch.setattr(terminal.os, "waitpid", fake_waitpid) + + assert terminal._reap_terminal_descendants(123, limit=3) == 3 + assert calls == [(-123, os.WNOHANG), (-123, os.WNOHANG), (-123, os.WNOHANG)] diff --git a/tests/test_tls_support.py b/tests/test_tls_support.py index a43e89962d0..06385744619 100644 --- a/tests/test_tls_support.py +++ b/tests/test_tls_support.py @@ -8,9 +8,10 @@ import os import ssl import subprocess +import sys +import tempfile import textwrap import time -import tempfile import unittest from contextlib import suppress from pathlib import Path @@ -79,6 +80,7 @@ def _start_server(port: int, cert: str = None, key: str = None) -> subprocess.Po [os.sys.executable, str(ROOT / "server.py")], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + **({"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}), ) return proc diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index 20806879e6d..d4a44965ae5 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -260,6 +260,9 @@ def test_live_tool_cards_use_grouping_only_when_simplified(self): assert "data-live-tid" in live_fn, ( "Live grouping must preserve data-live-tid so tool_start/tool_complete updates still replace the correct card." ) + assert "tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id" in live_fn, ( + "Live replay should replace restored cards for all known tool id aliases, not only tc.tid." + ) def test_activity_disclosure_state_is_session_and_turn_scoped(self): helper = _function_body(UI_JS, "ensureActivityGroup") diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 394841e36ed..b078129565b 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -14,6 +14,7 @@ import pathlib import re +import subprocess import threading import time import sys @@ -41,6 +42,35 @@ def read(rel): return (REPO / rel).read_text(encoding='utf-8') +def extract_js_function(src: str, name: str) -> str: + match = re.search(rf'(async\s+)?function\s+{re.escape(name)}\b', src) + assert match, f"{name}() not found" + open_paren = src.index("(", match.start()) + paren_depth = 1 + idx = open_paren + 1 + while paren_depth > 0 and idx < len(src): + ch = src[idx] + if ch == "(": + paren_depth += 1 + elif ch == ")": + paren_depth -= 1 + idx += 1 + brace = src.index("{", idx) + depth = 0 + end = None + for idx in range(brace, len(src)): + ch = src[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + end = idx + 1 + break + assert end is not None, f"{name}() body was not balanced" + return src[match.start():end] + + # ── api/updates.py ──────────────────────────────────────────────────────────── class TestUpdateChecker: @@ -613,6 +643,21 @@ def test_force_route_exists(self): ) +class TestHealthRouteContract: + def test_health_payload_includes_server_started_at(self): + src = read('api/routes.py') + health_start = src.index('def _handle_health') + payload_start = src.index('payload = {', health_start) + payload_end = src.index('if "oldest_run_age_seconds" in run_check:', payload_start) + payload = src[payload_start:payload_end] + assert '"server_started_at": SERVER_START_TIME' in payload, ( + "/health must expose server_started_at sourced from SERVER_START_TIME" + ) + assert '"uptime_seconds": round(time.time() - SERVER_START_TIME, 1)' in payload, ( + "/health must keep exposing uptime_seconds alongside server_started_at" + ) + + class TestUpdateSummaryRouteModelSelection: """Update summaries should use a known text auxiliary model before main model fallback.""" @@ -874,6 +919,388 @@ def test_wait_for_server_polls_health(self): "_waitForServerThenReload must call location.reload() once the server is ready" ) + def test_wait_for_server_requires_new_process_identity(self): + src = read('static/ui.js') + fn = extract_js_function(src, '_waitForServerThenReload') + assert 'baselineServerIdentity' in fn, ( + "_waitForServerThenReload() should capture and compare a baseline process identity" + ) + compact = re.sub(r'\s+', '', fn) + assert 'baselineServerIdentity.serverStartedAt!==null&&nextServerIdentity.serverStartedAt!==null&&nextServerIdentity.serverStartedAt!==baselineServerIdentity.serverStartedAt' in compact, ( + "_waitForServerThenReload() should compare server_started_at when it is available" + ) + assert 'baselineServerIdentity.uptimeSeconds!==null&&nextServerIdentity.uptimeSeconds!==null&&nextServerIdentity.uptimeSeconds=2 consecutive failed/non-OK probes) + followed by a healthy response is the reliable restart signal in that case — + without it the user is stranded on the restart banner until they manually + reload. The >=2 threshold + outage reset on a healthy old-server response + prevent a single transient network blip from reloading onto the old process.""" + src = read('static/ui.js') + fn = extract_js_function(src, '_waitForServerThenReload') + compact = re.sub(r'\s+', '', fn) + # Outage counter incremented on thrown fetch errors AND non-OK responses. + assert '_consecutiveOutages++' in compact, ( + "the /health probe must count failed/non-OK responses as outage evidence" + ) + # Sustained-outage threshold (>=2) gates the uptime-only fallback — not a single blip. + assert '_consecutiveOutages>=2' in compact, ( + "the outage fallback must require >=2 consecutive outages so a single " + "transient blip can't trigger a premature reload onto the old server" + ) + assert '_restartOutageObserved()&&' in compact, ( + "_waitForServerThenReload() must gate the uptime-only reload on a sustained outage" + ) + # Outage evidence resets when the OLD server answers healthy (blip, not restart). + assert '_consecutiveOutages=0' in compact, ( + "a healthy pre-restart-process response must reset the outage counter so " + "unrelated blips can't accumulate into a false positive" + ) + assert ('baselineServerIdentity.serverStartedAt===null&&nextServerIdentity.serverStartedAt===null' + in compact), ( + "the outage fallback must be scoped to the uptime-only-on-both-sides case" + ) + + + def test_wait_for_server_fallbacks_to_ready_on_missing_baseline(self): + """Healthy /health should reload immediately when baseline identity is missing.""" + src = read('static/ui.js') + normalize_fn = extract_js_function(src, '_normalizeHealthServerIdentity') + identity_fn = extract_js_function(src, '_healthResponseServerIdentity') + wait_fn = extract_js_function(src, '_waitForServerThenReload') + + script = f""" +let now = 0; +let reloads = 0; +let fetches = 0; +const responses = [ + {{ ok: true, data: {{ status: 'ok', server_started_at: null, uptime_seconds: 120 }} }}, +]; +global.window = {{}}; +global.document = {{ baseURI: 'http://127.0.0.1:8788/' }}; +global.location = {{ reload: () => {{ reloads += 1; }} }}; +global.$ = () => null; +global.Date = {{ now: () => now }}; +global.setTimeout = (cb, ms) => {{ now += ms || 0; cb(); return 0; }}; +global.fetch = async () => {{ + fetches += 1; + const next = responses.shift(); + if (!next) throw new Error('unexpected extra fetch'); + return {{ + ok: next.ok, + json: async () => next.data, + }}; +}}; +{normalize_fn} +{identity_fn} +{wait_fn} +(async () => {{ + await _waitForServerThenReload({{ interval: 1, maxMs: 10, baselineServerIdentity: null }}); + if (fetches !== 1) throw new Error('expected fallback baseline to reload on first healthy probe, got '+fetches); + if (reloads !== 1) throw new Error('expected exactly one reload on first healthy probe, got '+reloads); +}})().catch(err => {{ console.error(err.stack || err.message); process.exit(1); }}); +""" + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_wait_for_server_ignores_old_identity_and_reloads_on_new_identity(self): + """Healthy /health from the old process should not reload until identity changes.""" + src = read('static/ui.js') + normalize_fn = extract_js_function(src, '_normalizeHealthServerIdentity') + identity_fn = extract_js_function(src, '_healthResponseServerIdentity') + wait_fn = extract_js_function(src, '_waitForServerThenReload') + + script = f""" +let now = 0; +let reloads = 0; +let fetches = 0; +const responses = [ + {{ ok: true, data: {{ status: 'ok', server_started_at: '1001.234', uptime_seconds: 1000 }} }}, + {{ ok: true, data: {{ status: 'ok', server_started_at: '1001.234', uptime_seconds: 2000 }} }}, + {{ ok: true, data: {{ status: 'ok', server_started_at: '1001.235', uptime_seconds: 2010 }} }}, +]; +global.window = {{}}; +global.document = {{ baseURI: 'http://127.0.0.1:8788/' }}; +global.location = {{ reload: () => {{ reloads += 1; }} }}; +global.$ = () => null; +global.Date = {{ now: () => now }}; +global.setTimeout = (cb, ms) => {{ now += ms || 0; cb(); return 0; }}; +global.fetch = async () => {{ + fetches += 1; + const next = responses.shift(); + if (!next) throw new Error('unexpected extra fetch'); + return {{ + ok: next.ok, + json: async () => next.data, + }}; +}}; +{normalize_fn} +{identity_fn} +{wait_fn} +(async () => {{ + await _waitForServerThenReload({{ interval: 1, maxMs: 20, baselineServerIdentity: {{ serverStartedAt: '1001.234', uptimeSeconds: 999 }} }}); + if (fetches !== 3) throw new Error('expected old-process health to be ignored before identity changes, got '+fetches); + if (reloads !== 1) throw new Error('expected exactly one reload after new identity, got '+reloads); +}})().catch(err => {{ console.error(err.stack || err.message); process.exit(1); }}); +""" + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_wait_for_server_falls_back_to_uptime_when_started_at_is_missing(self): + src = read('static/ui.js') + normalize_fn = extract_js_function(src, '_normalizeHealthServerIdentity') + identity_fn = extract_js_function(src, '_healthResponseServerIdentity') + wait_fn = extract_js_function(src, '_waitForServerThenReload') + + script = f""" +let now = 0; +let reloads = 0; +let fetches = 0; +const responses = [ + {{ ok: true, data: {{ status: 'ok', server_started_at: null, uptime_seconds: 120 }} }}, + {{ ok: true, data: {{ status: 'ok', server_started_at: null, uptime_seconds: 2 }} }}, +]; +global.window = {{}}; +global.document = {{ baseURI: 'http://127.0.0.1:8788/' }}; +global.location = {{ reload: () => {{ reloads += 1; }} }}; +global.$ = () => null; +global.Date = {{ now: () => now }}; +global.setTimeout = (cb, ms) => {{ now += ms || 0; cb(); return 0; }}; +global.fetch = async () => {{ + fetches += 1; + const next = responses.shift(); + if (!next) throw new Error('unexpected extra fetch'); + return {{ + ok: next.ok, + json: async () => next.data, + }}; +}}; +{normalize_fn} +{identity_fn} +{wait_fn} +(async () => {{ + await _waitForServerThenReload({{ interval: 1, maxMs: 20, baselineServerIdentity: {{ serverStartedAt: null, uptimeSeconds: 120 }} }}); + if (fetches !== 2) throw new Error('expected uptime fallback to wait for a lower uptime, got '+fetches); + if (reloads !== 1) throw new Error('expected exactly one reload after uptime fallback identified a new process, got '+reloads); +}})().catch(err => {{ console.error(err.stack || err.message); process.exit(1); }}); +""" + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_wait_for_server_accepts_new_started_at_when_baseline_lacked_one(self): + src = read('static/ui.js') + normalize_fn = extract_js_function(src, '_normalizeHealthServerIdentity') + identity_fn = extract_js_function(src, '_healthResponseServerIdentity') + wait_fn = extract_js_function(src, '_waitForServerThenReload') + + script = f""" +let now = 0; +let reloads = 0; +let fetches = 0; +const responses = [ + {{ ok: true, data: {{ status: 'ok', server_started_at: '1001.300', uptime_seconds: 120 }} }}, +]; +global.window = {{}}; +global.document = {{ baseURI: 'http://127.0.0.1:8788/' }}; +global.location = {{ reload: () => {{ reloads += 1; }} }}; +global.$ = () => null; +global.Date = {{ now: () => now }}; +global.setTimeout = (cb, ms) => {{ now += ms || 0; cb(); return 0; }}; +global.fetch = async () => {{ + fetches += 1; + const next = responses.shift(); + if (!next) throw new Error('unexpected extra fetch'); + return {{ + ok: next.ok, + json: async () => next.data, + }}; +}}; +{normalize_fn} +{identity_fn} +{wait_fn} +(async () => {{ + await _waitForServerThenReload({{ interval: 1, maxMs: 20, baselineServerIdentity: {{ serverStartedAt: null, uptimeSeconds: 120 }} }}); + if (fetches !== 1) throw new Error('expected new server_started_at to trigger reload on first healthy probe, got '+fetches); + if (reloads !== 1) throw new Error('expected exactly one reload when replacement server exposes server_started_at, got '+reloads); +}})().catch(err => {{ console.error(err.stack || err.message); process.exit(1); }}); +""" + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_wait_for_server_reloads_when_replacement_health_has_no_identity_fields(self): + src = read('static/ui.js') + normalize_fn = extract_js_function(src, '_normalizeHealthServerIdentity') + identity_fn = extract_js_function(src, '_healthResponseServerIdentity') + wait_fn = extract_js_function(src, '_waitForServerThenReload') + + script = f""" +let now = 0; +let reloads = 0; +let fetches = 0; +const responses = [ + {{ ok: true, data: {{ status: 'ok', server_started_at: null, uptime_seconds: null }} }}, +]; +global.window = {{}}; +global.document = {{ baseURI: 'http://127.0.0.1:8788/' }}; +global.location = {{ reload: () => {{ reloads += 1; }} }}; +global.$ = () => null; +global.Date = {{ now: () => now }}; +global.setTimeout = (cb, ms) => {{ now += ms || 0; cb(); return 0; }}; +global.fetch = async () => {{ + fetches += 1; + const next = responses.shift(); + if (!next) throw new Error('unexpected extra fetch'); + return {{ + ok: next.ok, + json: async () => next.data, + }}; +}}; +{normalize_fn} +{identity_fn} +{wait_fn} +(async () => {{ + await _waitForServerThenReload({{ interval: 1, maxMs: 20, baselineServerIdentity: {{ serverStartedAt: '1001.234', uptimeSeconds: null }} }}); + if (fetches !== 1) throw new Error('expected identity-less healthy replacement to trigger reload on first probe, got '+fetches); + if (reloads !== 1) throw new Error('expected exactly one reload when replacement health exposes no identity fields, got '+reloads); +}})().catch(err => {{ console.error(err.stack || err.message); process.exit(1); }}); +""" + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_wait_for_server_reloads_when_full_baseline_loses_all_identity_fields(self): + src = read('static/ui.js') + normalize_fn = extract_js_function(src, '_normalizeHealthServerIdentity') + identity_fn = extract_js_function(src, '_healthResponseServerIdentity') + wait_fn = extract_js_function(src, '_waitForServerThenReload') + + script = f""" +let now = 0; +let reloads = 0; +let fetches = 0; +const responses = [ + {{ ok: true, data: {{ status: 'ok', server_started_at: null, uptime_seconds: null }} }}, +]; +global.window = {{}}; +global.document = {{ baseURI: 'http://127.0.0.1:8788/' }}; +global.location = {{ reload: () => {{ reloads += 1; }} }}; +global.$ = () => null; +global.Date = {{ now: () => now }}; +global.setTimeout = (cb, ms) => {{ now += ms || 0; cb(); return 0; }}; +global.fetch = async () => {{ + fetches += 1; + const next = responses.shift(); + if (!next) throw new Error('unexpected extra fetch'); + return {{ + ok: next.ok, + json: async () => next.data, + }}; +}}; +{normalize_fn} +{identity_fn} +{wait_fn} +(async () => {{ + await _waitForServerThenReload({{ interval: 1, maxMs: 20, baselineServerIdentity: {{ serverStartedAt: '1001.234', uptimeSeconds: 120 }} }}); + if (fetches !== 1) throw new Error('expected full-baseline identity loss to trigger reload on first probe, got '+fetches); + if (reloads !== 1) throw new Error('expected exactly one reload when replacement health drops all identity fields after a full baseline, got '+reloads); +}})().catch(err => {{ console.error(err.stack || err.message); process.exit(1); }}); +""" + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_wait_for_server_reloads_when_baseline_started_at_degrades_to_uptime_only(self): + src = read('static/ui.js') + normalize_fn = extract_js_function(src, '_normalizeHealthServerIdentity') + identity_fn = extract_js_function(src, '_healthResponseServerIdentity') + wait_fn = extract_js_function(src, '_waitForServerThenReload') + + script = f""" +let now = 0; +let reloads = 0; +let fetches = 0; +const responses = [ + {{ ok: true, data: {{ status: 'ok', server_started_at: null, uptime_seconds: 12 }} }}, +]; +global.window = {{}}; +global.document = {{ baseURI: 'http://127.0.0.1:8788/' }}; +global.location = {{ reload: () => {{ reloads += 1; }} }}; +global.$ = () => null; +global.Date = {{ now: () => now }}; +global.setTimeout = (cb, ms) => {{ now += ms || 0; cb(); return 0; }}; +global.fetch = async () => {{ + fetches += 1; + const next = responses.shift(); + if (!next) throw new Error('unexpected extra fetch'); + return {{ + ok: next.ok, + json: async () => next.data, + }}; +}}; +{normalize_fn} +{identity_fn} +{wait_fn} +(async () => {{ + await _waitForServerThenReload({{ interval: 1, maxMs: 20, baselineServerIdentity: {{ serverStartedAt: '1001.234', uptimeSeconds: 120 }} }}); + if (fetches !== 1) throw new Error('expected uptime-only healthy replacement to trigger reload on first probe, got '+fetches); + if (reloads !== 1) throw new Error('expected exactly one reload when started_at degrades to uptime-only health, got '+reloads); +}})().catch(err => {{ console.error(err.stack || err.message); process.exit(1); }}); +""" + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_apply_and_force_updates_capture_identity(self): + src = read('static/ui.js') + apply_fn = re.search(r'function\s+applyUpdates\b.*?\n\}', src, re.DOTALL) + force_fn = re.search(r'function\s+forceUpdate\b.*?\n\}', src, re.DOTALL) + assert apply_fn, "applyUpdates() not found" + assert force_fn, "forceUpdate() not found" + apply_body = apply_fn.group(0) + force_body = force_fn.group(0) + assert '_readHealthServerIdentity()' in apply_body, ( + "applyUpdates() must call _readHealthServerIdentity() before reload wait" + ) + assert '_readHealthServerIdentity()' in force_body, ( + "forceUpdate() must call _readHealthServerIdentity() before reload wait" + ) + assert '_waitForServerThenReload({baselineServerIdentity})' in apply_body, ( + "applyUpdates() must pass baselineServerIdentity to _waitForServerThenReload()" + ) + assert '_waitForServerThenReload({baselineServerIdentity})' in force_body, ( + "forceUpdate() must pass baselineServerIdentity to _waitForServerThenReload()" + ) + assert apply_body.index('_readHealthServerIdentity()') < apply_body.index('_waitForServerThenReload({baselineServerIdentity})'), ( + "applyUpdates() must capture baseline before reload scheduling" + ) + assert force_body.index('_readHealthServerIdentity()') < force_body.index("const res=await api('/api/updates/force'"), ( + "forceUpdate() must capture baseline before force POST" + ) + + def test_health_identity_helper_prefers_server_started_at_and_keeps_uptime_fallback(self): + src = read('static/ui.js') + normalize_fn = extract_js_function(src, '_normalizeHealthServerIdentity') + identity_fn = extract_js_function(src, '_healthResponseServerIdentity') + + script = f""" +{normalize_fn} +{identity_fn} +const preferred = _healthResponseServerIdentity({{ server_started_at: '1001.234', uptime_seconds: 900 }}); +if (!preferred || preferred.serverStartedAt !== '1001.234') {{ + throw new Error('expected server_started_at to be the preferred identity field'); +}} +if (preferred.uptimeSeconds !== 900) {{ + throw new Error('expected uptime_seconds to remain available for fallback comparisons'); +}} +const fallback = _healthResponseServerIdentity({{ server_started_at: null, uptime_seconds: 120 }}); +if (!fallback || fallback.serverStartedAt !== null || fallback.uptimeSeconds !== 120) {{ + throw new Error('expected uptime_seconds fallback identity when server_started_at is unavailable'); +}} +if (_healthResponseServerIdentity({{ server_started_at: null, uptime_seconds: null }}) !== null) {{ + throw new Error('expected null identity when /health exposes neither started_at nor uptime'); +}} +""" + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + def test_refresh_session_handles_restart_mode(self): """When _restartingForUpdate flag is set, refreshSession() must do a full page reload rather than hit /api/session (which will 502 while diff --git a/tests/test_webui_session_db_adapter.py b/tests/test_webui_session_db_adapter.py new file mode 100644 index 00000000000..81bef46d59f --- /dev/null +++ b/tests/test_webui_session_db_adapter.py @@ -0,0 +1,162 @@ +import json +from pathlib import Path + +import pytest + +import api.config as config +import api.models as models +import api.webui_session_db as session_db +from api.webui_session_db import WebUIJsonSessionDB + + +@pytest.fixture +def session_dir(tmp_path, monkeypatch): + path = tmp_path / "sessions" + path.mkdir() + monkeypatch.setattr(models, "SESSION_DIR", path) + return path + + +def _write_json_session(session_dir, sid="session_1", **overrides): + payload = { + "session_id": sid, + "title": "Adapter Session", + "workspace": str(session_dir.parent), + "model": "gpt-test", + "model_provider": "openai", + "created_at": 100.0, + "updated_at": 200.0, + "pinned": False, + "archived": False, + "profile": "default", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "world"}, + ], + "tool_calls": [{"id": "tool-1", "name": "demo"}], + } + payload.update(overrides) + payload["message_count"] = len(payload["messages"]) + path = session_dir / f"{sid}.json" + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return payload, path + + +def test_list_and_read_existing_json_sessions(session_dir): + payload, _path = _write_json_session(session_dir) + db = WebUIJsonSessionDB() + + rows = db.list_sessions() + loaded = db.read_session(payload["session_id"]) + + assert [row["session_id"] for row in rows] == [payload["session_id"]] + assert rows[0]["title"] == payload["title"] + assert rows[0]["message_count"] == 2 + assert loaded == payload + + +def test_metadata_update_survives_reload_and_preserves_messages(session_dir): + payload, path = _write_json_session(session_dir) + db = WebUIJsonSessionDB() + + db.update_metadata(payload["session_id"], {"title": "Renamed", "pinned": True}) + reloaded = json.loads(path.read_text(encoding="utf-8")) + + assert reloaded["title"] == "Renamed" + assert reloaded["pinned"] is True + assert reloaded["messages"] == payload["messages"] + assert reloaded["tool_calls"] == payload["tool_calls"] + assert reloaded["message_count"] == len(payload["messages"]) + + +def test_metadata_update_rejects_unsafe_fields(session_dir): + payload, path = _write_json_session(session_dir) + before = path.read_text(encoding="utf-8") + db = WebUIJsonSessionDB() + + with pytest.raises(ValueError): + db.update_metadata(payload["session_id"], {"messages": []}) + with pytest.raises(ValueError): + db.update_metadata(payload["session_id"], {"unknown_field": "unsafe"}) + + assert path.read_text(encoding="utf-8") == before + + +def test_metadata_update_refuses_metadata_only_stub(session_dir): + sid = "stub_session" + path = session_dir / f"{sid}.json" + path.write_text( + json.dumps({"session_id": sid, "title": "Stub"}, indent=2), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="metadata-only"): + WebUIJsonSessionDB().update_metadata(sid, {"title": "Nope"}) + + +def test_archive_unarchive_round_trip(session_dir): + payload, path = _write_json_session(session_dir) + db = WebUIJsonSessionDB() + + archived = db.archive(payload["session_id"]) + unarchived = db.archive(payload["session_id"], archived=False) + reloaded = json.loads(path.read_text(encoding="utf-8")) + + assert archived["archived"] is True + assert unarchived["archived"] is False + assert reloaded["archived"] is False + assert reloaded["messages"] == payload["messages"] + + +def test_read_only_operations_do_not_mutate_files(session_dir): + payload, path = _write_json_session(session_dir) + before_text = path.read_text(encoding="utf-8") + before_stat = path.stat() + db = WebUIJsonSessionDB() + + assert db.list_sessions() + assert db.read_session(payload["session_id"]) == payload + + after_stat = path.stat() + assert path.read_text(encoding="utf-8") == before_text + assert after_stat.st_mtime_ns == before_stat.st_mtime_ns + assert after_stat.st_size == before_stat.st_size + + +def test_sort_timestamp_falls_back_past_missing_values(): + assert WebUIJsonSessionDB._sort_timestamp({"last_message_at": None, "updated_at": 25.0}) == 25.0 + assert WebUIJsonSessionDB._sort_timestamp({"last_message_at": "", "created_at": "15.5"}) == 15.5 + + +def test_module_level_write_session_wrapper(session_dir): + payload, _path = _write_json_session(session_dir, sid="wrapper_session") + written = session_db.write_session(payload) + + assert written == payload + assert session_db.read_session(payload["session_id"]) == payload + + +def test_unified_session_db_flag_default_remains_false(monkeypatch, tmp_path): + cfg_path = tmp_path / "missing-config.yaml" + monkeypatch.setattr(config, "_get_config_path", lambda: cfg_path) + + config.reload_config() + + assert config.get_config()["experimental"]["unified_session_db"] is False + assert config.is_unified_session_db_enabled() is False + assert config.is_unified_session_db_enabled({"experimental": {"unified_session_db": True}}) is True + + +def test_adapter_docs_pin_runtime_wiring_preconditions(): + doc = (Path(__file__).resolve().parents[1] / "docs" / "architecture" / "unified-session-db.md").read_text( + encoding="utf-8" + ) + + assert "Runtime Wiring Preconditions" in doc + assert "per-session mutation locks" in doc + assert "in-memory `Session` cache and `_index.json`" in doc + assert "pending first turns" in doc + assert "test/migration helpers, not runtime persistence replacements" in doc + assert "Runtime wiring must add Session lock/cache/index parity" in ( + session_db.WebUIJsonSessionDB.update_metadata.__doc__ or "" + ) diff --git a/tests/test_workspace_git.py b/tests/test_workspace_git.py index 9ca564e61cd..488c0135ffa 100644 --- a/tests/test_workspace_git.py +++ b/tests/test_workspace_git.py @@ -292,6 +292,33 @@ def test_git_status_reports_untracked_files_inside_directories(tmp_path): assert not (nested / "a.txt").exists() +def test_git_discard_untracked_file_tolerates_concurrent_missing_file(tmp_path, monkeypatch): + import api.workspace_git as workspace_git + + repo = _init_repo(tmp_path / "repo") + (repo / "tracked.txt").write_text("one\n", encoding="utf-8") + _commit_all(repo) + transient = repo / "transient.txt" + transient.write_text("gone soon\n", encoding="utf-8") + + original_unlink_anchored = workspace_git.unlink_anchored + raced = {"seen": False} + + def remove_before_unlink(root, target): + if target == transient: + raced["seen"] = True + transient.unlink() + return original_unlink_anchored(root, target) + + monkeypatch.setattr(workspace_git, "unlink_anchored", remove_before_unlink) + + status = workspace_git.git_discard(repo, ["transient.txt"], delete_untracked=True) + + assert raced["seen"] is True + assert not transient.exists() + assert status["totals"]["changed"] == 0 + + def test_git_status_reports_ignored_files_without_counting_them_as_changed(tmp_path): from api.workspace_git import git_status @@ -829,6 +856,48 @@ def test_git_routes_selected_commit_and_structured_error(cleanup_test_sessions): assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["selected.txt"] +def test_git_discard_untracked_delete_uses_anchored_unlink_after_validation_race(tmp_path, monkeypatch): + import os + import shutil + + import api.workspace_git as workspace_git + from api.workspace import safe_resolve_ws as real_safe_resolve_ws + + repo = _init_repo(tmp_path / "repo") + (repo / "tracked.txt").write_text("tracked\n", encoding="utf-8") + _commit_all(repo) + + (repo / "d").mkdir() + (repo / "d" / "f").write_text("workspace untracked\n", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + victim = outside / "f" + victim.write_text("outside victim\n", encoding="utf-8") + + state = {"calls": 0, "swapped": False} + + def racing_safe_resolve(root, requested): + target = real_safe_resolve_ws(root, requested) + if requested == "d/f": + state["calls"] += 1 + # git_discard validates once for the Git pathspec and once immediately + # before deletion. Race the second validation-to-use window. + if requested == "d/f" and state["calls"] == 2 and not state["swapped"]: + shutil.rmtree(repo / "d") + os.symlink(outside, repo / "d") + state["swapped"] = True + return target + + monkeypatch.setattr(workspace_git, "safe_resolve_ws", racing_safe_resolve) + + with pytest.raises(ValueError, match="Path traversal blocked"): + workspace_git.git_discard(repo, ["d/f"], delete_untracked=True) + + assert state["swapped"] is True + assert victim.exists() + assert victim.read_text(encoding="utf-8") == "outside victim\n" + + def test_git_env_scrub_removes_redirecting_vars_and_preserves_temp_index(monkeypatch): from api.workspace_git import _clean_git_env