Compare commits

..
Author SHA1 Message Date
ethernet 191bae64e5 Revert "perf(docker-tests): blocking exec with DEVNULL for wait_for_container_ready"
This reverts commit 19ca99e863.
2026-07-15 12:46:04 -04:00
ethernet 19ca99e863 perf(docker-tests): blocking exec with DEVNULL for wait_for_container_ready
Retry of the blocking docker exec approach, this time using
stdout=DEVNULL/stderr=DEVNULL instead of capture_output=True.

The previous attempt regressed on CI (+135s) with capture_output.
Hypothesis: the docker daemon stdio pipe management for long-lived
exec sessions added overhead under 8-way parallel test execution.
DEVNULL should be lighter — the daemon never has to buffer output
or manage pipe read ends.

Also uses user="root" (not "hermes") to avoid deadlocking on
PUID/PGID remap tests where the hermes user UID is being changed
during cont-init.

The probe runs an until/sleep loop inside the container, replacing
N separate docker exec polls with a single blocking exec. On CI
where docker exec costs ~0.25s overhead per call, this should save
(N-1) × 0.25s per container where N is the number of polls
(typically 3-7).
2026-07-15 12:35:18 -04:00
ethernet 65addc2c4f Revert "perf(docker-tests): halve poll intervals from 0.5s to 0.25s"
This reverts commit a7c778e3ba.
2026-07-14 18:35:45 -04:00
ethernet a7c778e3ba perf(docker-tests): halve poll intervals from 0.5s to 0.25s
Reduce the default interval_s in poll_container, wait_for_docker_logs,
and inline time.sleep() calls in test_dashboard.py and
test_gateway_run_supervised.py from 0.5s to 0.25s.

CI profile with sleep tracking showed 116.2s of time.sleep() across
345 sleep calls — 23% of total wall time. The majority of these
sleeps were in poll_container (0.5s default) and inline polling
loops in the dashboard and gateway-supervised tests. Halving the
interval halves the sleep time for each poll cycle without changing
docker call count or overhead.

Unlike the blocking-exec experiment (which traded fewer calls for
longer per-call duration and regressed on CI), this change only
reduces idle sleep time — the docker exec calls stay the same.
2026-07-14 17:38:18 -04:00
ethernet 3fc4e413d2 feat(ci): track time.sleep in docker test profiler
The profiler now monkey-patches time.sleep alongside subprocess.run,
capturing the invisible "gap" time from polling loops (wait_for_container_ready,
poll_container, wait_for_log, etc.) that was previously unaccounted for.

The JSON report now includes per-test total_sleep_s, sleep_count, and
a sleeps[] array with caller location. The summary includes
total_sleep_s and total_wall_s (docker + sleep). The CI merge step
also aggregates sleep totals.

Local profile now shows: 201s docker + 75s sleep = 276s wall (38s
runner wall with 32-way parallelism). The biggest sleep consumer is
test_dashboard_insecure_env_var_no_longer_bypasses at 11.8s of
poll_container sleeps.
2026-07-14 16:22:05 -04:00
ethernet ca6ede33e1 docs(docker-tests): document why poll loop beats blocking exec in wait_for_container_ready
Tried replacing the N-times-docker-exec polling loop with a single
blocking docker exec (in-container until/sleep loop). CI profiling
showed this was a net regression: docker exec connection overhead on
shared runners is ~0.7-1s per call, while the existing poll approach
uses 3-7 quick 0.27s execs. The blocking approach traded fewer calls
for longer per-call duration and lost.

Reverted to the original poll loop, added a docstring note explaining
why so the next person does not re-attempt the same optimization.
2026-07-14 16:06:37 -04:00
ethernet 3e89edf830 perf(docker-tests): share containers across read-only tests
Add a module-scoped `shared_container` fixture to tests/docker/conftest.py
that boots one `sleep infinity` container per test module and tears it
down at module exit. Convert read-only tests that previously used
`docker run --rm --entrypoint sh/cat/test/su` (bypassing s6 to check
static image properties) or `docker run -d` + `docker exec` (starting
identical containers per test) to use `docker exec` on the shared
container instead.

Converted files:
  test_immutable_install_permissions.py — 2 throwaway runs → 2 execs
  test_license_file_present.py          — 1 throwaway run → 1 exec
  test_tini_compat_shim.py              — 1 throwaway run → 1 exec
  test_tui_prebuilt_bundle.py           — 2 throwaway runs → 2 execs
  test_dump_build_sha.py                — 2 throwaway runs → 2 execs
  test_immutable_install.py             — 3 detached runs → 1 shared + 1 isolated
  test_dashboard.py                     — 2 detached runs → 0 (use shared)

Local profiling shows docker run calls in these 7 files dropped from
~25 to 7 (the 7 are shared_container boots per module + the one test
that needs a restart). Each eliminated `docker run` was paying 1-9s
of s6 cont-init startup; the replacement `docker exec` calls average
0.10s — an ~50x speedup per operation.

Tests that mutate state (restarts, config changes, gateway starts)
still use their own containers via `container_name` + `start_container`.
2026-07-14 02:10:00 -04:00
ethernet 3ec1e82629 fix(ci): pass profile env var through run_tests.sh env -i barrier
Two bugs fixed:

1. scripts/run_tests.sh uses env -i (empty environment) and only
   passes through a hand-picked set of vars. HERMES_DOCKER_TEST_PROFILE
   and HERMES_DOCKER_PROFILE_OUT were not in that list, so the env var
   set in the CI workflow was stripped before reaching the pytest
   subprocesses — the plugin never activated and no JSON was produced.

2. run_tests_parallel.py spawns each test file in its own subprocess,
   so each would write to the same docker-test-profile.json, clobbering
   each other. Changed the default output path to include the PID
   (docker-test-profile-<pid>.json) and added a CI merge step that
   combines all per-PID files into a single docker-test-profile.json
   before uploading as an artifact.
2026-07-13 18:58:25 -04:00
ethernet 9f9ab13a64 feat(ci): profile docker integration test operations
Add a pytest profiling plugin (tests/docker/profiling.py) that
instruments every subprocess.run call targeting docker, collecting
per-test and per-subcommand wall-clock timings. Activated via
HERMES_DOCKER_TEST_PROFILE=1 — zero overhead when unset.

The CI docker.yml workflow now enables profiling and uploads the
JSON report as an artifact (docker-test-profile-<arch>), so we can
see exactly which docker operations (run, exec, restart, polling)
dominate the slow CI runs vs. fast local runs.

Output includes:
- Per-subcommand summary (call count, total/avg time, %)
- Top 10 slowest tests by docker operation time
- Top 10 slowest individual docker calls
- Full JSON report for offline analysis
2026-07-13 18:16:14 -04:00
ethernet 29b8cacfab fix(ci): add missing Δ Wait column to skipped job rows 2026-07-13 17:46:11 -04:00
ethernet e117478eb3 fix(ci): align baseline gantt bars to current job start
Baseline bars were positioned at their absolute timeline offset, which
included the baseline run's own wait time — making duration comparisons
hard since the bars were visually offset. Now each baseline bar starts
at the same left position as its corresponding current job, so the two
bars directly overlap for at-a-glance duration comparison.

Also removed the now-unused bl_t0 / bl_max / bl_jobs_timed variables
that tracked the baseline timeline position.
2026-07-13 17:46:11 -04:00
ethernet f0b7cf3836 feat(ci): show per-job wait times in timing report
Add wait time computation: for each job, wait_s = started_at - max(completed_at
of all jobs that finished before it started). This is a timestamp heuristic
(no workflow YAML dependency parse needed) that's accurate for pipeline-shaped
CI where the critical path is linear at each stage.

Shows up in:
- Job table: new Wait + Δ Wait columns
- Gantt chart: hatched bar segment before the run bar
- Step details: '(wait Xs)' annotation in the summary line
- Markdown summary: Total wait row with delta vs baseline
- Stats: total_wait / bl_total_wait in compute_stats

Also completes the skipped-jobs UI:
- Skipped stat card in stats cards
- Skipped row in markdown summary table

Backward compatible: old cached baselines without wait_s annotate on load.
2026-07-13 17:46:11 -04:00
ethernet 7beca22bc0 fix(ci): exclude skipped jobs from timing deltas and stats
Skipped jobs (conclusion == 'skipped') have null/zero-duration timestamps
that polluted every downstream computation: they counted as 'unchanged'
(0 vs 0) in faster/slower tallies, showed meaningless '0.0s (0%)' deltas
in the job table, rendered phantom gantt bars, and inflated wall/compute
totals.

Add is_skipped() helper and apply it consistently:
- compute_stats: exclude from wall/compute + faster/slower/unchanged;
  add 'skipped' and 'bl_skipped' counts
- _gantt_bars: filter from current bars, baseline bars, and axis calc
- _job_table: show 'skipped' label instead of durations/deltas
- _step_details: skip entirely (no meaningful step data)
- _regressions: exclude from both current and baseline sides
2026-07-13 17:46:11 -04:00
brooklyn! 2a25d53ee5 Merge pull request #63995 from NousResearch/bb/salvage-63842-zoom-restore-sync
fix(desktop): sync UI Scale control after zoom restore (supersedes #63842)
2026-07-13 17:43:42 -04:00
Brooklyn Nicholsonandalelpoan 39230d1738 refactor(desktop): funnel zoom apply+notify so restore can't desync
alelpoan's fix (emit hermes:zoom:changed after restore) is correct, but the
bug's root is duplication: setAndPersistZoomLevel and restorePersistedZoomLevel
each independently did setZoomLevel + send, and restore forgot the send.

Collapse both (and the lifecycle re-assert) into a single applyZoomLevel()
helper in zoom.ts that always applies-then-notifies — the regression can't
recur by omitting a send. Replace the source-grep test (which broke on main:
the sibling source-assertion pet test it copied was refactored to a behavioral
one, dropping the fs/path imports it relied on) with behavioral coverage of the
funnel, matching zoom.ts's "unit-testable without booting a BrowserWindow"
convention.

Co-authored-by: alelpoan <alelpoan@proton.me>
2026-07-13 17:39:03 -04:00
alelpoan 9f7a3cb1f6 test(desktop): cover restorePersistedZoomLevel renderer notification 2026-07-13 17:32:17 -04:00
alelpoan a1a4c8ce1f fix(desktop): sync UI Scale setting after zoom restore on window load 2026-07-13 17:32:17 -04:00
kshitijk4poor 10dc1571bc fix(deepinfra): align refresh and TTS availability
Forward explicit catalog refreshes and make the TTS availability gate follow the configured provider instead of unrelated credentials.
2026-07-14 02:59:39 +05:30
kshitijk4poor 2fc3f9c1ff fix(deepinfra): harden multimodal provider routing
Prevent credential forwarding across catalog redirects, retain explicit opt-in semantics for paid media backends, fail closed on invalid provider configuration, avoid mixed-catalog and output-limit assumptions, and reserve native STT provider names.
2026-07-14 02:59:39 +05:30
Georgi Atsev fe002eb124 feat(providers): Support DeepInfra as an LLM provider 2026-07-14 02:59:39 +05:30
brooklyn! ed8ce1f96c Merge pull request #63955 from NousResearch/bb/fix-windows-broken-login-bash
fix(windows): survive broken Git Bash login shells
2026-07-13 17:23:36 -04:00
ethernet 5d691374c3 fix(desktop): recognize little-endian Mach-O magic in native binary classifier
classifyNativeBinary only checked big-endian Mach-O/Fat magic bytes
(feedfacf, feedface, cafebabe). Real Darwin .node files from node-pty
prebuilds are stored little-endian on disk (cffaedfe = MH_CIGAM_64),
so every Darwin prebuild classified as null, and validateStagedBinaries
threw a platform mismatch on macOS — breaking npm run check for every
macOS contributor. CI didn't catch it because runners are Linux (ELF
path was correct) and tests only planted big-endian fake headers.

Add recognition for all six Mach-O/Fat byte orderings:
- MH_CIGAM (cefaedfe) — LE 32-bit
- MH_CIGAM_64 (cffaedfe) — LE 64-bit [the one real prebuilds use]
- FAT_CIGAM (bebafeca) — LE universal

Update makeFakeNode to write LE CIGAM_64 bytes for the darwin fixture
(matching real on-disk format) and add regression tests for all new
magic forms.
2026-07-13 17:22:17 -04:00
ethernet 7577834206 fix(ci): fail closed when workspace matrix discovery produces empty list
The set-matrix step wrote the npm workspace query result directly to
$GITHUB_OUTPUT. If discovery ever produced [], the matrix would expand
to zero check jobs, leaving the reusable workflow green without running
any JS/TS checks.

Now the step validates the result is a non-empty array before emitting
it, and exits 1 with a GitHub annotation if it's empty or jq failed.
2026-07-13 17:22:17 -04:00
ethernet 7a44a8fdec fix(desktop): prevent staging wrong-platform node-pty binary for cross targets
stageNodePty received electron-builder's { platform, arch } but unconditionally
copied host build/Release, staging a host binary (e.g. macOS Mach-O) for a
foreign target (e.g. linux-arm64). The fallback rebuild also didn't pass the
target arch to electron-rebuild.

Now:
- build/Release is only staged when target platform+arch match the host
- cross-platform targets with no matching prebuild fail closed
- same-platform different-arch rebuild passes --arch to electron-rebuild
- post-staging validation reads .node magic bytes (ELF/Mach-O/PE) and rejects
  any binary whose platform doesn't match the target

Adds stageNodePtyInto() (testable core) and classifyNativeBinary() (pure),
plus 11 regression tests covering the cross-target scenarios.
2026-07-13 17:22:17 -04:00
ethernet 47d56b802a test(desktop): run scripts/ tests in vitest 2026-07-13 17:22:17 -04:00
ethernet 79061f447c feat(ci): show passed/failed jobs in summary
this is useful for debugging actions where a job's pass/fail isn't the same as it is in the gha ui (e.g. neutral status jobs)
2026-07-13 17:22:17 -04:00
ethernet a6857faf48 test(desktop): fix remaining act() warnings in gateway-connecting-overlay
Move setGatewayState + rerender calls inside act() blocks and make the
synchronous soft-switch test async so all state updates are wrapped.
Eliminates the last 4 act() warnings (44 → 0).
2026-07-13 17:22:17 -04:00
ethernet b034fa7026 test(desktop): convert zoom source-regex test to behavior test
Extracted zoomWiringForWindowKind() + ZOOM_WINDOW_CONFIG into zoom.ts so
the pet-overlay-
opts-out / chat-windows-keep-zoom contract is tested via the pure
config,
not by reading source. Callers in main.ts now use
zoomWiringForWindowKind()
instead of inline { zoom: false } / default { zoom: true }.
2026-07-13 17:22:17 -04:00
ethernet c3aa81be2f feat(ci): load npm workspaces from package.json 2026-07-13 17:22:17 -04:00
ethernet 727025a2f3 test(desktop): replace windows-child-process.test.ts regex with real tests 2026-07-13 17:22:17 -04:00
ethernet c5794c505e change(lint): don't ignore config files in eslint conf 2026-07-13 17:22:17 -04:00
ethernet 955c5b73c5 test(desktop): move node tests to vitest as well 2026-07-13 17:22:17 -04:00
ethernet 74c69dc8ed tests(tui): longer timeout for wrap ansi test 2026-07-13 17:22:17 -04:00
ethernet c008f41bb9 fix(desktop): stage-native-deps falls back to electron-rebuild when no native binary exists
When neither a prebuild nor a compiled build/Release/*.node is found for
the target platform-arch, stage-native-deps.mjs now runs
electron-rebuild -f -w node-pty to compile one from source before
re-copying build/Release into the staged dist.

This makes the staging script self-sufficient — it always produces a
working native binary dir regardless of whether npm ci --ignore-scripts
skipped postinstall or whether node-pty publishes prebuilds for the
target (e.g. linux-x64 has no prebuild).
2026-07-13 17:22:17 -04:00
ethernet ef61436967 test(desktop): fix React act() warnings across all desktop test files
Add vitest.setup.ts with IS_REACT_ACT_ENVIRONMENT=true + auto-cleanup,
and wrap render()/fireEvent() calls in act() across 8 test files:

- provider-config-panel.test.tsx: wrap renderPanel + fireEvent in act
- providers-settings.test.tsx: wrap renderProvidersSettings + fireEvent
in act
- use-prompt-actions/index.test.tsx: add actRender helper, wrap all 38
render
  calls, wrap Harness handle methods (submitText/cancelRun/steerPrompt/
  restoreToMessage) in act at the onReady callback level
- attachments.test.tsx: make renderWithI18n async + wrap in act
- skills/index.test.tsx: make renderSkills async + wrap fireEvent in act
- messaging/index.test.tsx: make renderMessaging async + wrap fireEvent
in act
- gateway-connecting-overlay.test.tsx: wrap all render/rerender in act
- preview-pane.test.tsx: wrap render calls in act, make tests async

Reduces act warnings from 44 to 4 (remaining are fake-timer + pre-render
store mutation edge cases). All 146 test files / 1180 tests still pass.
2026-07-13 17:22:17 -04:00
ethernet 999d63b517 test(desktop): fix a handful of broken tests 2026-07-13 17:22:17 -04:00
ethernet 56bec611e5 cleanup(desktop): note that ts imports don't need extension in one comment 2026-07-13 17:22:17 -04:00
ethernet 2762555038 cleanup(desktop): remove 'use strict' in ts & mjs
it's implied by ts and mjs files already
2026-07-13 17:22:17 -04:00
ethernet 6800ec9d66 change(ci/desktop): move desktop app build into check job 2026-07-13 17:22:17 -04:00
ethernet 7c98c65163 cleanup(desktop): lint&fmt all 2026-07-13 17:22:17 -04:00
ethernet 0fac4cd8e9 test(desktop): fix session preview registry tests fails
clearing the registry sets localstorage values, so we must clear it
afterwards
2026-07-13 17:22:17 -04:00
ethernet 6016997a72 test(desktop): stub CSS global in .test.tsx files 2026-07-13 17:22:17 -04:00
ethernet 92025df393 test(desktop): fix attachment list test not querying the selector correctly 2026-07-13 17:22:17 -04:00
ethernet 66c097ab78 test(desktop): warn when using document in tsx tests
this is almost always a mistake
2026-07-13 17:22:17 -04:00
ethernet f382ff84f7 change(desktop): add vitest config for the desktop app 2026-07-13 17:22:17 -04:00
ethernet 3c408684ea test(desktop): rename panes test to describe its behavior
when the desktop app was first build, this was intentionally meant to
not save, but later it was added as a feature, and the test never
updated.
2026-07-13 17:22:17 -04:00
ethernet 1c557bb98a cleanup(ci): make all tsbuildinfo gitignored 2026-07-13 17:22:17 -04:00
ethernet 47c47d6a03 test(desktop): handful of broken tests changed to match intended behavior 2026-07-13 17:22:17 -04:00
ethernet d6c76cfbfa test(tui): extract cursor-layout + fast-echo helpers for real unit tests
textInputCursorSourceOfTruth.test.ts read textInput.tsx as text and
regexed it to check that cursorLayout() is called with curRef.current
(not the stale cur React state), and that the fast-echo backspace/append
stdout writes are paired with noteCursorAdvance calls.

Extract three pure functions from textInput.tsx:
- resolveCursorLayout(display, cur, curRefCurrent, columns): wraps
  cursorLayout(display, curRefCurrent, columns), making the
  curRef.current-over-cur choice a directly testable pure call instead of
  a regex match on the render-site call expression.
- fastBackspaceEffect(current, cursor) / fastAppendEffect(current, cursor,
  text): return a single object bundling {newValue, newCursor, write,
  advanceDelta} for each fast-echo path. Bundling the stdout write and the
  noteCursorAdvance delta into one return value makes the pairing
  impossible to silently drift apart (a caller can't get  without
  ), instead of relying on the two call sites appearing near
  each other in source text.

textInput.tsx's render site and backspace/append handlers now call these
helpers directly, preserving exact existing behavior (the same '\b \b'
write sequence, noteCursorAdvance(-1)/noteCursorAdvance(text.length) calls).

textInputCursorSourceOfTruth.test.ts imports and calls the three pure
functions directly with a deliberately stale cur vs a fresh curRefCurrent
to reconstruct the exact regression scenario, and asserts the bundled
effect objects -- no readFileSync, no regex against textInput.tsx's
source text. Full ui-tui suite (107 files, 1117 tests) still green.
2026-07-13 17:22:17 -04:00
ethernet bffc098d09 test(desktop): fix relative import in oauth-net-request.test.ts, drop dead source-regex test
oauth-session-request.test.ts regexed main.ts source text (extracting the
fetchJsonViaOauthSession function body and matching regexes against it) to
check Electron net.request doesn't set the forbidden Content-Length header
and does call request.write(body).

That behavior is already covered for real by oauth-net-request.test.ts,
which imports the actual serializeJsonBody/setJsonRequestHeaders helpers
from oauth-net-request.ts and asserts on a mock request object's setHeader
calls -- it only needed its relative import corrected to include the .ts
extension (Node's ESM loader doesn't resolve extensionless relative
specifiers). Removed the now-fully-superseded oauth-session-request.test.ts
and its dangling package.json wiring.
2026-07-13 17:22:17 -04:00
ethernet 1b8f1504b3 test(desktop): extract profile-delete routing decision for real unit tests
profile-delete-respawn.test.ts regexed main.ts source text to check that
prepareProfileDeleteRequest returns the torn-down profile name, and that
the hermes:api ipcMain handler captures that return value and routes to
the primary backend instead of respawning a pool backend for the just-
deleted profile.

Extract the pure decision logic into profile-delete-routing.ts (no
Electron import):
- profileNameFromDeleteRequest(request): parses a DELETE
  /api/profiles/<name> path, moved verbatim (already pure).
- decideProfileDeleteAction(profile, deps): the branch decision (noop /
  teardown-primary / teardown-pool) and the profile name to return,
  parameterized over isDefaultProfile/isValidProfileName/primaryProfileKey.
- resolveRouteProfile(tornDownProfile, profile): the
   routing ternary from the hermes:api
  handler.

prepareProfileDeleteRequest in main.ts now calls decideProfileDeleteAction
for the decision and only performs the async side effects (teardown +
writeActiveDesktopProfile) the decision calls for.

profile-delete-routing.test.ts replaces profile-delete-respawn.test.ts
(which was never wired into test:desktop:platforms), importing the pure
functions directly and asserting real return values across every branch
-- no readFileSync, no regex-on-source. Wired the new test file into
test:desktop:platforms in package.json.
2026-07-13 17:22:17 -04:00
ethernet ec2cb3ab47 test(desktop): extract Windows hermes-resolution helpers for real unit tests
windows-hermes-resolution.test.ts regexed main.ts source text to check
three Windows resolution bugs that caused desktop reinstall loops:
1. findOnPath()'s PATHEXT extension order (must try real extensions before
   the empty one, or an extensionless Git-Bash hermes shim shadows
   hermes.cmd/.exe).
2. handOffWindowsBootstrapRecovery()'s --update vs --repair choice (must
   gate on any real-install signal, not just the hermes.exe shim).
3. unwrapWindowsVenvHermesCommand()'s probe-before-trust behavior (must
   canImportHermesCli() before returning a venv python, or a broken venv
   gets re-selected forever).

Extract all three into pure, dependency-injected functions in
windows-hermes-path.ts (no Electron import): buildPathExtCandidates(),
chooseUpdaterArgs(), resolveVenvHermesCommand(). main.ts's
findOnPath/handOffWindowsBootstrapRecovery/unwrapWindowsVenvHermesCommand
now call these with their existing helpers (fileExists, canImportHermesCli,
getVenvPython, etc.) passed through as deps.

windows-hermes-path.test.ts replaces windows-hermes-resolution.test.ts,
importing the pure functions directly and asserting real return values
with fake/injected dependencies (fake venvs, fake probes) -- no readFileSync,
no regex-on-source.
2026-07-13 17:22:17 -04:00
ethernet 4527943e91 test(desktop): extract hiddenWindowsChildOptions + stopBackendChild for real unit tests
windows-child-process.test.ts regexed main.ts and bootstrap-runner.ts
source text to check that spawn/execFileSync call sites wrapped their
options with hiddenWindowsChildOptions(), and that backend teardown
chose the right kill strategy.

Extract both into dependency-free sibling modules:
- windows-child-options.ts: hiddenWindowsChildOptions(options,
isWindows)
  now takes isWindows as an injectable param (defaults to the real
  platform check). main.ts and bootstrap-runner.ts both import the same
  implementation instead of each defining their own copy.
- backend-child.ts: stopBackendChild(child, deps) with
forceKillProcessTree
  and isWindows injected, so the SIGTERM-vs-tree-kill branching is
directly
  testable with a fake child + a spy.

windows-child-options.test.ts replaces windows-child-process.test.ts,
calling the real functions with fake spawn/execFileSync-shaped objects
and asserting on the actual returned options / kill call.
2026-07-13 17:22:17 -04:00
ethernet 5265b3002c feat(agent): ban regex-scanning source code in tests
Add a new AGENTS.md antipattern section: tests should NEVER read source
code and regex against it!
2026-07-13 17:22:17 -04:00
ethernet 5d41ad710c fix(js): fix long-time broken tests
(
2026-07-13 17:22:17 -04:00
ethernet 7b3f3047ab feat(ci): run JS tests in CI, add npm run check in ws root 2026-07-13 17:22:17 -04:00
Brooklyn Nicholson f0d22e4b6c test(windows): capture bootstrap only in msys wrap-command tests
Same fix as the base-env bootstrap tests: init_session's post-failure
non-login probe was overwriting captured["script"] with `true`. Capture
the first (bootstrap) _run_bash call via setdefault.
2026-07-13 17:02:57 -04:00
Brooklyn Nicholson c707165212 test(windows): capture only the bootstrap _run_bash, not the failure probe
init_session now fires a follow-up non-login `bash -c true` probe when the
login bootstrap fails, so the two bootstrap-script assertions must capture
the first call only (setdefault), not the probe that overwrote it.
2026-07-13 16:51:08 -04:00
yingliang-zhang c44de99854 test(approval): pin blocking E2E flow to manual mode 2026-07-13 16:50:01 -04:00
ethernet 6ce160a5a0 Revert "fix(tests): force manual approval mode in E2E blocking tests"
This reverts commit 55624e10b9.
2026-07-13 16:50:01 -04:00
kshitijk4poor af7dceaf77 fix(context): persist fallback compaction breaker 2026-07-14 02:19:40 +05:30
LeonSGP43 5ce827cac9 fix(context): count fallback compactions as ineffective 2026-07-14 02:19:40 +05:30
ethernet 55624e10b9 fix(tests): force manual approval mode in E2E blocking tests
Commit 62a76bd3d (feat: make smart approvals the default, #62661)
changed approvals.mode default from "manual" to "smart". The
TestBlockingApprovalE2E tests did not patch the approval mode, so
check_all_command_guards routed through _smart_approve() first —
calling call_llm() which tried all auxiliary providers, failed (no
API keys in test env), and returned "escalate" before falling through
to the gateway blocking path. The LLM failure cascade took longer
than the test's 2.5s wait window (50 × 0.05s), so the notify
callback had not fired yet when assert len(notified) == 1 ran.

Force {"mode": "manual"} via patch in setup_method/teardown_method,
matching the pattern already used by test_blocking_approval_uses_
canonical_timeout in the same class.
2026-07-13 16:41:00 -04:00
Brooklyn Nicholson c4622a1d5b fix(windows): survive broken Git Bash login shells
#63621 fixed path quoting, but Ainz's Git for Windows still dies on
`bash -l` itself (`Directory \drivers\etc`). Hermes then fell back to
bash -l *per command*, so every write_file/terminal call failed the same way.

After a failed login snapshot, probe non-login bash -c; if it works, skip
-l for the session. Also skip a stale HERMES_GIT_BASH_PATH that fails a
noprofile probe in favor of %LOCALAPPDATA%\hermes\git portable bash.
2026-07-13 16:14:18 -04:00
brooklyn! caed552de1 Merge pull request #63948 from NousResearch/bb/salvage-48281-stream-pin
fix(desktop): pin unscoped streams + clear view sync on switch (supersedes #48281)
2026-07-13 16:12:07 -04:00
da52ffea14 fix(desktop): pin unscoped streams + clear view sync on switch
Live deltas from session A were attaching to session B after New Session
when events arrived without session_id — fallback used the newly focused
activeSessionId (#47709).

Pin unscoped stream events to the session that received message.start
(#48281). Also reset RAF-pending view staging on new/resume/create so a
stale background flush cannot repaint over the switched chat (#47743).

Co-authored-by: Ray <rayjun0412@gmail.com>
Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp>
2026-07-13 16:10:33 -04:00
ethernet 7fe1cb384e feat(ci): python test speedups 2026-07-13 15:29:20 -04:00
ethernet f6d1fd511c feat(desktop): auto-fetch remote base branch before worktree add
When the base is an origin/… ref, fetch just that branch so the
local tracking ref is fresh before `git worktree add -b new origin/main`.
Fetch failures (offline / no remote) are silently ignored — git uses
whatever local ref exists, or raises a clear error if it's missing.
2026-07-13 14:53:34 -04:00
ethernet 6f7ee72be5 feat(desktop): base-branch picker for new worktree dialog
The sidebar "New worktree" button branched off whatever HEAD you were
on — now a filterable Popover+Command combobox lets you pick any local
or remote-tracking branch as the base, defaulting to origin/HEAD.

Backend listBaseBranches() queries refs/heads + refs/remotes via
for-each-ref, flags origin/HEAD (falling back to the local default for
no-remote repos). Mirrored on the Python REST API side
(base_branch_list + /api/git/base-branches route).
2026-07-13 14:53:34 -04:00
kshitijk4poor 2627933f33 fix(agent): distinguish missing from broken compression locks 2026-07-14 00:14:55 +05:30
Rory FordandClaude Opus 4.8 8f29c9f4e3 fix(agent): fail closed on unexpected compression-lock acquisition errors
Splits the single broad `except Exception` in the compression-lock
acquire path into two handlers: AttributeError/TypeError (version skew —
the lock method is missing, or predates the `ttl_seconds=` kwarg) still
fails OPEN as before, since that's known-safe to proceed without a lock.
Any other exception now fails CLOSED (skips compression this cycle)
instead of treating every failure as "no lock subsystem present" and
letting a second compressor run concurrently and fork the session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:14:55 +05:30
kshitijk4poor 915f1bf1bc fix(api): reserve cron fire work during drain 2026-07-13 23:26:10 +05:30
kshitijk4poor ffc10cc659 fix(gateway): quiesce API and cron work during drains
Reserve API requests before their first await, include every drain-owned work type in runtime status, and pause local cron dispatch while a gateway drain is active.
2026-07-13 23:26:10 +05:30
kshitijk4poor 104ffeae23 fix(gateway): complete API-server shutdown drain 2026-07-13 23:26:10 +05:30
Bartok9 021ee34546 fix(gateway): drain in-flight api_server runs on shutdown
Closes #63529

Root cause: GatewayRunner._drain_active_agents only waited on
_running_agents + cron in-flight counts. Desktop/API sessions are
tracked solely inside APIServerAdapter (_inflight_agent_runs +
_active_run_agents), so stop/restart logged active_at_start=0 and
systemd SIGKILL'd mid-tool work.

Fix: APIServerAdapter.active_agent_work_count() plus
GatewayRunner._active_api_run_count() folded into the drain wait,
status updates, timeout result, and shutdown logs — same pattern as
_active_cron_job_count for #60432.

Verification: pytest tests/gateway/test_api_server_active_work_drain.py
tests/gateway/test_cron_active_work_drain.py -q → 19 passed
2026-07-13 23:26:10 +05:30
kshitijk4poor 0512f06a6a fix(auth): centralize pool auth normalization
Normalize Anthropic setup-token metadata for every PooledCredential construction path, persist corrected manual entries, heal legacy rows on load without copying global fallback credentials into profiles, and map the contributor email for release attribution.
2026-07-13 23:20:48 +05:30
Manuel Guttmann 1215fbbd76 test(auth): cover sk-ant-oat OAuth normalization (#63737) 2026-07-13 23:20:48 +05:30
Manuel Guttmann 77763f00fb fix(auth): normalize Anthropic sk-ant-oat pool creds to OAuth
Manually-added Anthropic setup-tokens defaulted to api_key and were sent
via x-api-key, which Anthropic rejects -> 429. Infer OAuth from the
sk-ant-oat prefix in from_dict so all ingest paths agree with
_is_oauth_token(). Fixes #63737.
2026-07-13 23:20:48 +05:30
ethernet 7fdae5d22a fix(desktop): ensure node-pty spawn-helper is executable
resolves issue https://x.com/dineshgadge/status/2076024678452539691
2026-07-13 13:25:00 -04:00
brooklyn! 7f7a40381e Merge pull request #63864 from NousResearch/bb/salvage-63822-fallback-draft
fix(desktop): keep draft fallback rows across autosave echo (supersedes #63822)
2026-07-13 12:58:57 -04:00
Brooklyn NicholsonandHexLab98 3615545bca fix(desktop): keep draft fallback rows across autosave echo
Add fallback only updates local editor state; complete pairs are filtered
before onChange. The post-#7b5ba205 resync effect then saw the unchanged
persisted chain and wiped the draft — button looked dead.

Ignore value updates that match the last chain we emitted; still resync
on real external changes (profile/config reload).

Co-authored-by: HexLab98 <liruixinch@outlook.com>
2026-07-13 12:57:34 -04:00
teknium1 af250d8494 docs(delegation): clarify background lifetime 2026-07-13 07:28:21 -07:00
teknium1 d2b2be0f01 chore(release): map delegation contributor 2026-07-13 07:28:21 -07:00
teknium1 d0e9a42cec fix(delegation): harden durable completion delivery 2026-07-13 07:28:21 -07:00
teknium1 67f4e1b4a9 feat(delegation): persist background completions 2026-07-13 07:28:21 -07:00
John Lussier 94a7705bdd fix(gateway): deduplicate completion delivery 2026-07-13 07:28:21 -07:00
Teknium bd740f203b test(approval): isolate smart observer redaction failure 2026-07-13 04:31:55 -07:00
Teknium d921005e25 chore(release): map @kavioavio in AUTHOR_MAP 2026-07-13 04:31:55 -07:00
TekniumandSergei Ivanov d48bf743f2 fix(approval): scope smart deny owner overrides to one operation
Co-authored-by: Sergei Ivanov <kavi@local.hermes>
2026-07-13 04:31:55 -07:00
SahilRakhaiya05 f96b2e6ef7 fix(whatsapp_cloud): gate interactive taps on DM allowlist 2026-07-13 16:15:04 +05:30
kshitijk4poor ac705b52c9 fix(sessions): validate imported session payloads
Reject metadata that would make session queries fail, bound import work, and detach cyclic lineage links. Guard lineage traversal against pre-existing corrupt cycles.
2026-07-13 15:41:00 +05:30
Shannon Sands b51d365ef0 feat(dashboard): add session import flow 2026-07-13 15:41:00 +05:30
Teknium f813c7ddad chore(release): map @Tortugasaur desktop commits 2026-07-13 03:00:43 -07:00
Luigi Razon 3510b18814 feat(desktop): add profile-aware approval mode control 2026-07-13 03:00:43 -07:00
Teknium dfeedf613d fix(patch): ignore inert context-only hunks (#63678) 2026-07-13 02:42:18 -07:00
hellno b03c94dbed fix(approval): emit observer hooks for smart verdicts 2026-07-13 02:00:09 -07:00
Teknium 2bd721cebc test(kanban): remove duplicate final-results footer 2026-07-13 01:49:35 -07:00
Teknium 98b4562947 fix(kanban): make Done-card results actionable 2026-07-13 01:49:35 -07:00
iborazzi deae8e3b4d feat(kanban): surface final_result for Done cards; show run summary when task.result is empty 2026-07-13 01:49:35 -07:00
liuhao1024 e4ea0a0ed7 fix(config): preserve string-typed config values 2026-07-12 23:44:16 -07:00
Teknium a10081f83b test(image-gen): cover Codex capability HTTP boundary 2026-07-12 23:43:49 -07:00
Teknium 402969670d fix(image-gen): classify unsupported Codex image accounts 2026-07-12 23:43:49 -07:00
Teknium 8a5f8379ed fix(agent): honor custom-provider extra_body for multi-model catalogs
_custom_provider_model_matches() only compared the session model
against the entry's single 'model' field. A custom provider declaring
a multi-model catalog (providers.<name>.models mapping / models list)
whose default model differed from the session model silently failed to
match — dropping the entry's extra_body entirely. Real impact: an
OpenAI custom provider pinning service_tier=flex via extra_body ran
every request at STANDARD tier (~2.3x billing) with zero signal.

- Model matching now accepts the session model when it appears in the
  entry's models catalog (dict keys or list), case-insensitive;
  single-model 'model' field behavior unchanged; entries with neither
  still match everything.
- Usage report ('hermes -z --usage-file') now carries service_tier
  (the tier requested via request_overrides.extra_body) so batch
  pipelines can audit the billed tier per run.

Validation: 8 new tests; live E2E via real 'hermes -p sweeper -z'
with httpx-level wire capture — service_tier=flex present in the
outgoing /v1/responses body and in the usage report.
2026-07-12 23:30:52 -07:00
Jeffrey Quesnelle 902379ea3e Merge pull request #63624 from NousResearch/fix/desktop-new-chat-submit-drift-abort
fix(desktop): stop the submit drift guard from aborting every new chat
2026-07-13 02:14:18 -04:00
agent 80b58ec71d fix(kanban): spawn goal_mode workers with -Q so the goal loop actually runs
_default_spawn sets HERMES_KANBAN_GOAL_MODE=1 but launched 'chat -q' without
-Q; _run_kanban_goal_loop_q only executes in the quiet single-query branch,
so goal-mode never ran for dispatcher-spawned workers — they got one turn,
printed text, exited rc=0, and tripped the protocol-violation circuit
breaker (2026-06-09, cards t_d9cbe312 et al). Root-cause report + upstream
issue draft in kanban workspace t_720c5c60.
2026-07-12 23:10:33 -07:00
Teknium 0709714a6c chore(release): map @yinkev in AUTHOR_MAP 2026-07-12 23:09:41 -07:00
Teknium 8030b01a2a fix(kanban): harden durable artifact handoff 2026-07-12 23:09:41 -07:00
Kevin Yin e6c42b5d80 fix(kanban): preserve scratch completion artifacts 2026-07-12 23:09:41 -07:00
Teknium 5d524d0427 fix: reject empty credential pool leases (#63620) 2026-07-12 23:07:02 -07:00
brooklyn! ba7e5b052a Merge pull request #63621 from NousResearch/bb/salvage-63470-bash-paths
fix(windows): bash-safe snapshot paths after #63113 (supersedes #63470)
2026-07-13 02:05:43 -04:00
Brooklyn Nicholsonandxxxigm f2fcf89c1f fix(windows): bash-safe snapshot paths after #63113
#63113 rewrote native drive paths in ShellFileOperations, but init_session
/_wrap_command still embedded C:/... hermes-snap paths from get_temp_dir.
MSYS arg-converts those during bash -l and surfaces Directory \drivers\etc
— including for relative write_file targets, since the wrapper is the fault.

Add _bash_safe_path, override BaseEnvironment._quote_shell_path on
LocalEnvironment (no base→local import), and normalize mixed /c/Users\...
paths in file ops.

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
2026-07-13 01:59:58 -04:00
emozilla 8c288760d0 fix(desktop): stop the submit drift guard from aborting every new chat
The #54527 context pin (7acaff5ef) snapshots the selected stored session
and route token at submit entry and aborts when either changes mid-flight.
But a NEW chat's create pipeline legitimately moves both: on success,
createBackendSessionForSend re-homes selection and navigates to the chat
it just minted. Judged against the pre-create draft baseline that read as
a user switch, so every first send of a new chat aborted before
prompt.submit — message dropped, no DB row persisted (row creation is
lazy, server-side in prompt.submit), and the window stranded on a route
whose REST reads 404 "Session not found" forever.

Fix: after a successful create, verify no one re-homed during create's
post-commit await via the active-session ref (a non-null return
guarantees create set it; every switch path retargets it synchronously),
then re-pin the drift baseline to the created chat. A mid-create switch
still aborts through create's own null return, or through the active-ref
check for the post-commit window. Re-pinning also restores the correct
stored-id association for the optimistic-message state updates, which the
pinned pre-create null had degraded.

Tests: red-first regression for the new-chat send, an abort case for a
switch landing in create's post-commit window, and the sleep/wake
new-chat stub made faithful to the real create (it sets the active ref
before returning — the inert stub is what let this ship green).
2026-07-13 01:55:39 -04:00
Teknium acb3bde097 chore(release): map @jakelongvu-bot in AUTHOR_MAP 2026-07-12 22:43:57 -07:00
Jake Long Vu c5e841ab0e fix(approval): honor canonical gateway timeout 2026-07-12 22:43:57 -07:00
325 changed files with 14365 additions and 2209 deletions
+4
View File
@@ -115,6 +115,10 @@
# HF_BASE_URL=https://router.huggingface.co/v1 # Override default base URL
# OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL
# DeepInfra — 100+ top open models, pay-per-use.
# Get your key at: https://deepinfra.com/dash/api_keys
# DEEPINFRA_API_KEY=
# =============================================================================
# LLM PROVIDER (Qwen OAuth)
# =============================================================================
+1 -1
View File
@@ -10,7 +10,7 @@ outputs:
description: Run Python tests / ruff / ty / windows-footguns.
value: ${{ steps.classify.outputs.python }}
frontend:
description: Run the TypeScript typecheck matrix + desktop build.
description: Run the TypeScript testing matrix + desktop build.
value: ${{ steps.classify.outputs.frontend }}
docker_meta:
description: Docker setup and meta files have changed.
+13 -9
View File
@@ -70,11 +70,11 @@ jobs:
with:
event_name: ${{ needs.detect.outputs.event_name }}
typecheck:
name: TypeScript
js-tests:
name: JS & TS checks
needs: detect
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/typecheck.yml
uses: ./.github/workflows/js-tests.yml
docs-site:
name: Docs Site
@@ -139,7 +139,7 @@ jobs:
needs:
- tests
- lint
- typecheck
- js-tests
- docs-site
- history-check
- contributor-check
@@ -154,14 +154,18 @@ jobs:
steps:
- name: Evaluate job results
env:
RESULTS: ${{ toJSON(needs.*.result) }}
NEEDS: ${{ toJSON(needs) }}
run: |
echo "$RESULTS" | python3 -c "
echo "$NEEDS" | python3 -c "
import json, sys
results = json.load(sys.stdin)
failed = [r for r in results if r == 'failure']
needs = json.load(sys.stdin)
failed = [name for name, info in needs.items() if info['result'] == 'failure']
for name, info in sorted(needs.items()):
result = info['result']
icon = '✅' if result in ('success', 'skipped') else '❌'
print(f'{icon} {name}: {result}')
if failed:
print(f'::error::{len(failed)} job(s) failed')
print(f'::error::{len(failed)} job(s) failed: {\", \".join(failed)}')
sys.exit(1)
print('All checks passed (or were skipped)')
"
+61
View File
@@ -143,9 +143,70 @@ jobs:
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
# Profile every docker subprocess call so we can diagnose why
# the suite is slow on CI vs. local. Each per-file subprocess
# writes its own docker-test-profile-<pid>.json; the merge
# step below combines them into a single report.
HERMES_DOCKER_TEST_PROFILE: "1"
run: |
scripts/run_tests.sh tests/docker/ --file-timeout 600
- name: Merge docker test profiles
if: always()
run: |
python3 -c "
import json, glob, sys
files = sorted(glob.glob('docker-test-profile-*.json'))
if not files:
print('No per-PID profile files found — profiling may not have activated')
sys.exit(0)
merged = {'tests': [], 'summary': {}}
for f in files:
with open(f) as fh:
data = json.load(fh)
merged['tests'].extend(data.get('tests', []))
# Recompute summary aggregates from merged tests.
subcmd_totals = {}
subcmd_counts = {}
total_sleep = 0
total_sleeps = 0
for t in merged['tests']:
for sub, info in t.get('by_subcommand', {}).items():
subcmd_totals[sub] = subcmd_totals.get(sub, 0) + info['total_s']
subcmd_counts[sub] = subcmd_counts.get(sub, 0) + info['count']
total_sleep += t.get('total_sleep_s', 0)
total_sleeps += t.get('sleep_count', 0)
total_docker = sum(subcmd_totals.values())
merged['summary'] = {
'total_tests': len(merged['tests']),
'total_calls': sum(subcmd_counts.values()),
'total_sleeps': total_sleeps,
'total_docker_s': round(total_docker, 3),
'total_sleep_s': round(total_sleep, 3),
'total_wall_s': round(total_docker + total_sleep, 3),
'by_subcommand': {
sub: {
'count': subcmd_counts[sub],
'total_s': round(subcmd_totals[sub], 3),
'avg_s': round(subcmd_totals[sub] / subcmd_counts[sub], 3) if subcmd_counts[sub] else 0,
}
for sub in sorted(subcmd_totals, key=lambda s: subcmd_totals[s], reverse=True)
},
}
with open('docker-test-profile.json', 'w') as fh:
json.dump(merged, fh, indent=2)
print(f'Merged {len(files)} per-PID profiles ({len(merged[\"tests\"])} tests, {total_docker:.0f}s docker + {total_sleep:.0f}s sleep = {total_docker + total_sleep:.0f}s wall)')
"
- name: Upload docker test profile
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: docker-test-profile-${{ matrix.arch }}
path: docker-test-profile.json
retention-days: 14
if-no-files-found: warn
# ---------------------------------------------------------------------------
# Stitch both per-arch digests into a single tagged multi-arch manifest.
# This is a registry-side operation — no building, no layer re-push —
+49
View File
@@ -0,0 +1,49 @@
# .github/workflows/js-tests.yml
name: JS Tests
on:
workflow_call:
jobs:
workspaces:
name: List npm workspaces
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.set-matrix.outputs.packages }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
- id: set-matrix
run: |
PACKAGES=$(npm query .workspace | jq -c '[.[].location]')
if [ "$PACKAGES" = "[]" ] || [ -z "$PACKAGES" ]; then
echo "::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently)."
exit 1
fi
echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
check:
name: Typecheck & Test
runs-on: ubuntu-latest
needs: workspaces
strategy:
matrix:
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
fail-fast: false # report all failures, not just the first one
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- uses: ./.github/actions/retry
with:
# --ignore-scripts: TS & tests don't need native deps
command: npm ci --ignore-scripts
- run: npm run --prefix ${{ matrix.package }} check
-51
View File
@@ -1,51 +0,0 @@
# .github/workflows/typecheck.yml
name: Typecheck
on:
workflow_call:
jobs:
typecheck:
name: Check TypeScript
runs-on: ubuntu-latest
strategy:
matrix:
package:
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
fail-fast: false # report all failures, not just the first one
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# --ignore-scripts: typecheck only needs the TS sources + type defs, not
# native builds. Skipping install scripts drops node-pty's node-gyp
# header fetch — the transient flake that killed this job pre-`tsc` — and
# is faster. retry covers the remaining registry blips.
- uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
- run: npm run --prefix ${{ matrix.package }} typecheck
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
# which does NOT exercise Vite/Rolldown module resolution — so an
# unresolvable package export (e.g. a transitive @assistant-ui/tap that no
# longer exports "./react-shim") slips past typecheck and only explodes when
# users build apps/desktop from source on install/update. Run the real
# `vite build` here so that class of break fails in CI instead.
desktop-build:
name: Build desktop app
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# Keep install scripts here: the production build may need node-pty's
# native binary. retry handles the transient install-time fetch flakes.
- uses: ./.github/actions/retry
with:
command: npm ci
- run: npm run --prefix apps/desktop build
+1 -1
View File
@@ -69,7 +69,7 @@ hermes_cli/web_dist/
apps/desktop/build/
apps/desktop/dist/
apps/desktop/release/
apps/desktop/*.tsbuildinfo
*.tsbuildinfo
# Web UI assets — synced from @nous-research/ui at build time via
# `npm run sync-assets` (see web/package.json).
+55
View File
@@ -1354,3 +1354,58 @@ not the specific names.
Reviewers should reject new change-detector tests; authors should convert
them into invariants before re-requesting review.
### Never read source code in tests
A test that reads a source file's text is testing *the shape of the
source code*, not its behavior. This is a hard antipattern, banned outright.
Any test that reads a .py, .ts, .tsx, etc., file is suspect.
**Why it's actively harmful, not just weak:**
- It passes when the implementation is subtly broken (the regex matches a
call site that exists but is wired wrong) and fails when a correct
refactor changes formatting, variable names, or control flow with
identical runtime behavior. Both directions of failure are wrong.
- It can't be run against a built/bundled/minified artifact, so it silently
stops testing anything the moment code moves, gets renamed, or a
dependency reformats it.
- It actively blocks refactors: reviewers see "keeps a pattern intact" tests
fail during pure structural cleanup with no behavior change, and either
hand-wave the failure (dangerous) or waste time updating regexes that add
nothing (waste).
- It gives false confidence. a green suite full of source-regex tests
looks like coverage but has never once executed the code path it claims
to guard.
**Do not write:**
```ts
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
test('backend spawn hides the Windows console', () => {
assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/)
})
```
**Do write — extract the logic into a small pure/DI-testable function and
call it for real:**
```ts
// backend-spawn.ts
export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') {
if (!isWindows || 'windowsHide' in options) return options
return { ...options, windowsHide: true }
}
// backend-spawn.test.ts
test('windowsHide defaults to true on Windows, is left alone elsewhere', () => {
assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true)
assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined)
assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false)
})
```
If the logic lives inline in a god-file (`main.ts`, `cli.py`,
`gateway/run.py`) and extracting it feels disruptive: that's the actual
signal to do the extraction, not to regex around it.
+16 -9
View File
@@ -38,19 +38,22 @@ def _permission_option_supports_kind(kind: str) -> bool:
return True
def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]:
def _build_permission_options(
*, allow_permanent: bool, smart_denied: bool = False,
) -> list[PermissionOption]:
"""Return ACP options that match Hermes approval semantics."""
options = [
PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"),
PermissionOption(
options = [PermissionOption(
option_id="allow_once", kind="allow_once", name="Allow once",
)]
if not smart_denied:
options.append(PermissionOption(
option_id="allow_session",
# ACP has no session-scoped kind, so use the closest persistent
# hint while keeping Hermes semantics in the option id.
kind="allow_always",
name="Allow for session",
),
]
if allow_permanent:
))
if allow_permanent and not smart_denied:
options.append(
PermissionOption(
option_id="allow_always",
@@ -59,7 +62,7 @@ def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption
),
)
options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny"))
if _permission_option_supports_kind("reject_always"):
if not smart_denied and _permission_option_supports_kind("reject_always"):
options.append(
PermissionOption(
option_id="deny_always",
@@ -129,11 +132,15 @@ def make_approval_callback(
description: str,
*,
allow_permanent: bool = True,
smart_denied: bool = False,
**_: object,
) -> str:
from agent.async_utils import safe_schedule_threadsafe
options = _build_permission_options(allow_permanent=allow_permanent)
options = _build_permission_options(
allow_permanent=allow_permanent,
smart_denied=smart_denied,
)
tool_call = _build_permission_tool_call(command, description)
coro = request_permission_fn(
+19 -3
View File
@@ -187,10 +187,26 @@ def _normalized_custom_base_url(value: Any) -> str:
def _custom_provider_model_matches(agent_model: str, entry: Dict[str, Any]) -> bool:
provider_model = str(entry.get("model", "") or "").strip().lower()
if not provider_model:
agent_model_norm = str(agent_model or "").strip().lower()
# Multi-model entries (v12+ `providers.<name>.models` mapping / legacy
# `models:` list): the agent's model matching ANY catalog entry counts.
# Without this, a provider whose `model`/`default_model` differs from the
# session model silently fails to match and per-provider request settings
# (extra_body, e.g. OpenAI service_tier) are dropped — billing the whole
# session at the wrong tier (July 2026 sweeper incident: flex config
# ignored, ~2.3x overbilling).
models = entry.get("models")
catalog: List[str] = []
if isinstance(models, dict):
catalog = [str(k).strip().lower() for k in models.keys()]
elif isinstance(models, (list, tuple)):
catalog = [str(m).strip().lower() for m in models]
if catalog and agent_model_norm in catalog:
return True
return provider_model == str(agent_model or "").strip().lower()
provider_model = str(entry.get("model", "") or "").strip().lower()
if not provider_model and not catalog:
return True
return provider_model == agent_model_norm
def _custom_provider_extra_body_for_agent(
+66 -6
View File
@@ -474,6 +474,10 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
"kilocode": "google/gemini-3-flash-preview",
"ollama-cloud": "nemotron-3-nano:30b",
"tencent-tokenhub": "hy3-preview",
# NB: no "deepinfra" entry — its aux model lives on the ProviderProfile
# (plugins/model-providers/deepinfra: default_aux_model), which
# _get_aux_model_for_provider() reads first. Duplicating it here would be
# dead data that drifts when the profile's value is bumped.
}
# Legacy alias — callers that haven't been updated to _get_aux_model_for_provider()
@@ -489,6 +493,33 @@ _PROVIDER_VISION_MODELS: Dict[str, str] = {
"zai": "glm-5v-turbo",
}
def _resolve_provider_vision_default(provider: str) -> Optional[str]:
"""Return the provider's preferred default vision model id, or None.
Static entries in :data:`_PROVIDER_VISION_MODELS` win first (xiaomi /
zai have dedicated vision-only model names that don't live in any
discoverable catalog). Otherwise the provider's :class:`ProviderProfile`
gets a chance to supply one via its ``default_vision_model()`` hook —
that's where catalog-backed providers (DeepInfra) resolve a live default,
keeping the discovery logic inside their plugin instead of a name-check
branch here.
"""
static = _PROVIDER_VISION_MODELS.get(provider)
if static:
return static
try:
from providers import get_provider_profile
profile = get_provider_profile(provider)
except Exception:
return None
if profile is None:
return None
try:
return profile.default_vision_model()
except Exception:
return None
# Providers whose endpoint does not accept image input, even though the
# provider's broader ecosystem has vision models available elsewhere. When
# `auxiliary.vision.provider: auto` sees one of these as the main provider,
@@ -5178,6 +5209,7 @@ def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Di
_VISION_AUTO_PROVIDER_ORDER = (
"openrouter",
"nous",
"deepinfra",
)
@@ -5234,6 +5266,21 @@ def _resolve_strict_vision_backend(
return resolve_provider_client("openai-codex", model, is_vision=True)
if provider == "anthropic":
return _try_anthropic()
if provider == "deepinfra":
# DeepInfra exposes vision-capable models (Llama-4 Scout/Maverick,
# Qwen3-VL, Gemma 3, Gemini) on the same OpenAI-compatible endpoint
# as its chat models. The default is discovered live via the profile's
# default_vision_model() hook (key-gated, chat-surface + vision tag) so
# we don't pin a hardcoded id that may rot when DeepInfra retires a
# model, and this module stays provider-agnostic.
vision_model = model or _resolve_provider_vision_default("deepinfra")
if not vision_model:
logger.debug(
"Vision auto-detect: deepinfra catalog unreachable or "
"returned no vision-tagged models — skipping"
)
return None, None
return resolve_provider_client("deepinfra", vision_model, is_vision=True)
if provider == "custom":
return _try_custom_endpoint()
return None, None
@@ -5319,16 +5366,29 @@ def resolve_vision_provider_client(
# _PROVIDER_VISION_MODELS provides per-provider vision model
# overrides when the provider has a dedicated multimodal model
# that differs from the chat model (e.g. xiaomi → mimo-v2-omni,
# zai → glm-5v-turbo). Nous is the exception: it has a dedicated
# strict vision backend with tier-aware defaults, so it must not
# fall through to the user's text chat model here.
# 2. OpenRouter (vision-capable aggregator fallback)
# zai → glm-5v-turbo). DeepInfra is similar but resolves its
# default vision model live from the catalog (see
# :func:`_resolve_provider_vision_default`). Nous is the
# exception: it has a dedicated strict vision backend with
# tier-aware defaults, so it must not fall through to the
# user's text chat model here.
# 2. OpenRouter (vision-capable aggregator fallback)
# 3. Nous Portal (vision-capable aggregator fallback)
# 4. Stop
# 4. DeepInfra (OpenAI-compatible; vision model discovered
# live from the catalog — tried when
# DEEPINFRA_API_KEY is set)
# 5. Stop
main_provider = _read_main_provider()
main_model = _read_main_model()
if main_provider and main_provider not in {"auto", ""}:
vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model)
# A provider-specific vision default wins over the user's chat model:
# static overrides (xiaomi/zai) and catalog-backed discovery (the
# DeepInfra profile hook) both yield a *known* vision-capable model,
# whereas the pinned chat model is usually NOT multimodal (e.g. the
# DeepSeek-V4-Flash default) and _main_model_supports_vision can't be
# trusted to catch that. Only fall back to the chat model when no
# provider default is available (catalog unreachable).
vision_model = _resolve_provider_vision_default(main_provider) or main_model
if main_provider == "nous":
sync_client, default_model = _resolve_strict_vision_backend(
main_provider, vision_model
+8 -1
View File
@@ -282,7 +282,14 @@ def _record_codex_app_server_compaction(
# The app server has already completed a real compaction boundary. Its
# usage update (when supplied) is therefore the same real-vs-real
# effectiveness verdict used by the normal compression path.
if hasattr(compressor, "_verify_compaction_cleared_threshold"):
record_boundary = getattr(
type(compressor), "record_completed_compaction", None
)
if callable(record_boundary):
# Codex owns this summary. A prior Hermes deterministic-fallback
# flag must not leak into the native boundary's quality verdict.
record_boundary(compressor, used_fallback=False)
elif hasattr(compressor, "_verify_compaction_cleared_threshold"):
compressor._verify_compaction_cleared_threshold = True
if not getattr(turn, "token_usage_last", None):
compressor.last_prompt_tokens = -1
+108 -10
View File
@@ -736,6 +736,7 @@ class ContextCompressor(ContextEngine):
self._last_aux_model_failure_model = None
self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0
self._fallback_compression_streak = 0
self._verify_compaction_cleared_threshold = False
self._last_compression_made_progress = False
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
@@ -773,6 +774,7 @@ class ContextCompressor(ContextEngine):
self._last_aux_model_failure_model = None
self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0
self._fallback_compression_streak = 0
self._verify_compaction_cleared_threshold = False
self._last_compression_made_progress = False
self._summary_failure_cooldown_until = 0.0
@@ -790,12 +792,84 @@ class ContextCompressor(ContextEngine):
self._session_id = session_id or ""
self._summary_failure_cooldown_until = 0.0
self._last_summary_error = None
self._fallback_compression_streak = 0
self.get_active_compression_failure_cooldown()
self._load_fallback_compression_streak()
def on_session_start(self, session_id: str, **kwargs) -> None:
"""Bind session-scoped compression state for a new or resumed session."""
super().on_session_start(session_id, **kwargs)
self.bind_session_state(kwargs.get("session_db", getattr(self, "_session_db", None)), session_id)
boundary_reason = kwargs.get("boundary_reason")
old_session_id = kwargs.get("old_session_id")
session_db = kwargs.get("session_db", getattr(self, "_session_db", None))
previous_fallback_streak = self._fallback_compression_streak
if boundary_reason == "compression" and old_session_id:
getter = getattr(session_db, "get_compression_fallback_streak", None)
if callable(getter):
try:
stored_streak = getter(old_session_id)
if isinstance(stored_streak, (int, float, str)):
previous_fallback_streak = max(0, int(stored_streak))
except (TypeError, ValueError, sqlite3.Error) as exc:
logger.debug("compression parent fallback streak lookup failed: %s", exc)
except Exception as exc:
logger.debug(
"compression parent fallback streak lookup failed (non-sqlite): %s",
exc,
)
self.bind_session_state(session_db, session_id)
if boundary_reason == "compression":
# Rotation creates a fresh child row before this callback. Preserve
# the logical conversation's streak until boundary bookkeeping
# persists the updated value onto the child row.
self._fallback_compression_streak = previous_fallback_streak
def _load_fallback_compression_streak(self) -> None:
session_db = getattr(self, "_session_db", None)
session_id = getattr(self, "_session_id", "")
getter = getattr(session_db, "get_compression_fallback_streak", None)
if not session_id or not callable(getter):
return
try:
stored_streak = getter(session_id)
self._fallback_compression_streak = max(
0,
int(stored_streak)
if isinstance(stored_streak, (int, float, str))
else 0,
)
except (TypeError, ValueError, sqlite3.Error) as exc:
logger.debug("compression fallback streak lookup failed: %s", exc)
except Exception as exc:
logger.debug("compression fallback streak lookup failed (non-sqlite): %s", exc)
def _persist_fallback_compression_streak(self) -> None:
session_db = getattr(self, "_session_db", None)
session_id = getattr(self, "_session_id", "")
setter = getattr(session_db, "set_compression_fallback_streak", None)
if not session_id or not callable(setter):
return
try:
setter(session_id, self._fallback_compression_streak)
except sqlite3.Error as exc:
logger.debug("compression fallback streak persist failed: %s", exc)
except Exception as exc:
logger.debug("compression fallback streak persist failed (non-sqlite): %s", exc)
def record_completed_compaction(self, *, used_fallback: bool = False) -> None:
"""Record one completed boundary and its summary quality."""
self._verify_compaction_cleared_threshold = True
if used_fallback:
self._fallback_compression_streak += 1
if not self.quiet_mode:
logger.warning(
"Compaction completed with a deterministic fallback summary. "
"fallback_compression_streak=%d",
self._fallback_compression_streak,
)
elif self._fallback_compression_streak:
self._fallback_compression_streak = 0
self._persist_fallback_compression_streak()
def get_active_compression_failure_cooldown(self) -> Optional[Dict[str, Any]]:
"""Return the live compression-failure cooldown for the bound session."""
@@ -893,6 +967,12 @@ class ContextCompressor(ContextEngine):
max_tokens: int | None = None,
) -> None:
"""Update model info after a model switch or fallback activation."""
runtime_changed = any((
model != self.model,
provider != self.provider,
base_url != self.base_url,
api_mode != self.api_mode,
))
self.model = model
self.base_url = base_url
self.api_key = api_key
@@ -948,6 +1028,9 @@ class ContextCompressor(ContextEngine):
self.last_compression_rough_tokens = 0
self.awaiting_real_usage_after_compression = False
self._ineffective_compression_count = 0
if runtime_changed:
self._fallback_compression_streak = 0
self._persist_fallback_compression_streak()
self._verify_compaction_cleared_threshold = False
self._last_compression_made_progress = False
@@ -1137,6 +1220,10 @@ class ContextCompressor(ContextEngine):
# Anti-thrashing: track whether last compression was effective
self._last_compression_savings_pct: float = 100.0
self._ineffective_compression_count: int = 0
# Consecutive completed deterministic-fallback boundaries. Unlike the
# real-usage effectiveness counter, ordinary fitting responses must not
# reset this breaker; only a healthy completed summary does.
self._fallback_compression_streak: int = 0
# Set after a completed compression boundary; consumed by the next
# provider-reported prompt count in update_from_response().
self._verify_compaction_cleared_threshold: bool = False
@@ -1190,8 +1277,10 @@ class ContextCompressor(ContextEngine):
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
# Any real provider reading below the trigger proves the prompt
# fits again. Clear the episode latch even when this response was
# not the one immediately following compaction.
# fits again. Clear the real-usage effectiveness latch even
# when this response was not immediately after compaction. The
# independent fallback streak is boundary-scoped and survives
# ordinary fitting responses during context regrowth.
self._ineffective_compression_count = 0
else:
self.last_rough_tokens_when_real_prompt_fit = 0
@@ -1285,6 +1374,10 @@ class ContextCompressor(ContextEngine):
tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens
if tokens < self.threshold_tokens:
return False
return not self._automatic_compression_blocked()
def _automatic_compression_blocked(self) -> bool:
"""Return whether automatic compaction is in cooldown or tripped."""
# Do not trigger compression while the summary LLM is in cooldown.
# On a 429/transient failure _generate_summary() sets a cooldown and
# returns None; compress() then inserts a static fallback marker and
@@ -1301,18 +1394,23 @@ class ContextCompressor(ContextEngine):
"Compression deferred — summary LLM in cooldown for %.0fs more",
_cooldown_remaining,
)
return False
return True
# Anti-thrashing: back off if recent compressions were ineffective
if self._ineffective_compression_count >= 2:
if (
self._ineffective_compression_count >= 2
or self._fallback_compression_streak >= 2
):
if not self.quiet_mode:
logger.warning(
"Compression skipped — last %d compaction attempts did not "
"restore enough context headroom. Consider /new to start a "
"fresh session, or /compress <topic> for focused compression.",
"Compression skipped — repeated compaction attempts did not "
"restore healthy context. ineffective=%d fallback=%d. "
"Consider /new to start fresh, or /compress <topic> for "
"focused compression.",
self._ineffective_compression_count,
self._fallback_compression_streak,
)
return False
return True
return True
return False
# ------------------------------------------------------------------
# Tool output pruning (cheap pre-pass, no LLM call)
+133 -29
View File
@@ -28,6 +28,7 @@ these paths see no behavioural change.
from __future__ import annotations
import inspect
import logging
import os
import tempfile
@@ -52,6 +53,29 @@ COMPACTION_STATUS = (
)
def _lock_api_is_absent_on_session_db(lock_db: Any) -> bool:
"""Whether the live in-memory SessionDB class structurally predates locks.
In the supported hot-reload skew, this module is new while the already
imported ``hermes_state.SessionDB`` class (and its live instances) is old.
Only that exact class identity may fail open. Proxies, nominal lookalikes,
non-callables, and descriptor failures must fail closed. Static lookup
avoids invoking a present-but-broken descriptor.
"""
try:
from hermes_state import SessionDB
missing = object()
return (
type(lock_db) is SessionDB
and inspect.getattr_static(
SessionDB, "try_acquire_compression_lock", missing
) is missing
)
except Exception:
return False
def _compression_lock_holder(agent: Any) -> str:
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
@@ -480,6 +504,21 @@ def compress_context(
force=force,
)
# Every automatic entrypoint must honor compressor-owned cooldown and
# breaker state. Gateway hygiene constructs a fresh AIAgent, so the
# persisted fallback streak is loaded by bind_session_state() before this.
if not force:
blocked = getattr(
type(agent.context_compressor),
"_automatic_compression_blocked",
None,
)
if callable(blocked) and blocked(agent.context_compressor):
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
# Lazy feasibility check — run the auxiliary-provider probe + context
# length lookup just-in-time on the first compression attempt instead of
# at AIAgent.__init__. Saves ~400ms cold off every short session that
@@ -541,21 +580,31 @@ def compress_context(
_lock_sid = agent.session_id or ""
_lock_holder: Optional[str] = None
# Probe whether the lock subsystem is actually available on this
# SessionDB instance. A process running mismatched module versions
# (e.g. ``conversation_compression.py`` reloaded after a pull but the
# long-lived ``hermes_state.SessionDB`` class still bound to the
# pre-#34351 version in memory) has the call site but not the method.
# In that case ``try_acquire_compression_lock`` raises AttributeError —
# NOT a ``sqlite3.Error`` — so the method's own fail-open guard never
# runs and the exception propagates to the outer agent loop, which
# prints the error and retries. Because compression never succeeds,
# the token count never drops and the loop re-triggers compaction
# forever (the "API call #47/#48/#49 ... has no attribute
# try_acquire_compression_lock" spin). Fail OPEN here: if the lock
# subsystem is missing or broken in any unexpected way, skip locking
# and proceed with compression. Skipping the lock risks a rare
# concurrent-compression session fork; an infinite no-progress loop
# that never compresses at all is strictly worse.
# SessionDB instance. A process running mismatched module versions can have
# this call site while its long-lived SessionDB instance predates the lock
# API. Only that structural absence is safe to fail open for: compression
# must make progress rather than spin forever after an update. Once the
# method has been resolved, every exception from its implementation fails
# closed because proceeding without a lock can fork the session lineage.
_try_acquire_lock = None
_lock_lookup_error: Optional[Exception] = None
_legacy_session_db_without_lock_api = False
if _lock_db is not None:
try:
_legacy_session_db_without_lock_api = _lock_api_is_absent_on_session_db(
_lock_db
)
except Exception as exc:
_lock_lookup_error = exc
if _lock_lookup_error is None and not _legacy_session_db_without_lock_api:
try:
_try_acquire_lock = _lock_db.try_acquire_compression_lock
if not callable(_try_acquire_lock):
_lock_lookup_error = TypeError(
"compression lock API is present but not callable"
)
except Exception as exc:
_lock_lookup_error = exc
try:
_lock_ttl = float(getattr(agent, "_compression_lock_ttl_seconds", 300.0) or 300.0)
except (TypeError, ValueError):
@@ -564,25 +613,58 @@ def compress_context(
_lock_refresher: Optional[_CompressionLockLeaseRefresher] = None
if _lock_db is not None and _lock_sid:
_lock_holder = _compression_lock_holder(agent)
try:
_lock_acquired = _lock_db.try_acquire_compression_lock(
_lock_sid, _lock_holder, ttl_seconds=_lock_ttl
if _lock_lookup_error is not None:
# Attribute lookup itself failed for a reason other than a missing
# lock API. It is unsafe to proceed without a lock in that case.
_lock_holder = None
logger.warning(
"compression lock lookup raised unexpectedly for session=%s "
"(%s: %s) — skipping compression this cycle",
_lock_sid, type(_lock_lookup_error).__name__, _lock_lookup_error,
)
except Exception as _lock_err:
# Broken/absent lock subsystem (version skew, etc.). Log once
# per session and proceed WITHOUT the lock rather than letting
# the exception spin the outer loop.
_lock_holder = None # we don't own anything to release
_lock_acquired = False
elif _try_acquire_lock is None:
# The lock API itself is absent on this in-memory instance. Log once
# and proceed unlocked so an update-version skew cannot leave the
# outer auto-compression loop making no progress forever.
_lock_holder = None
if getattr(agent, "_last_compression_lock_error_sid", None) != _lock_sid:
agent._last_compression_lock_error_sid = _lock_sid
logger.warning(
"compression lock subsystem unavailable for session=%s "
"(%s: %s) — proceeding without lock. This usually means a "
"stale in-memory module after an update; restart the "
"process (or `hermes update`) to resync.",
"— proceeding without lock. This usually means a stale "
"in-memory module after an update; restart the process "
"(or `hermes update`) to resync.",
_lock_sid,
)
_lock_acquired = True # acquired-but-unlocked compatibility path
else:
try:
_lock_acquired = _try_acquire_lock(
_lock_sid, _lock_holder, ttl_seconds=_lock_ttl
)
except Exception as _lock_err:
# The method exists and entered its implementation but failed.
# Do not mistake an internal AttributeError or TypeError for
# version skew: fail closed and preserve session lineage. A
# failure after SQLite committed the acquire can leave our
# holder row behind, so release it best-effort before returning
# unchanged messages; release is holder-qualified and safe when
# acquisition never succeeded.
try:
_lock_db.release_compression_lock(_lock_sid, _lock_holder)
except Exception as _release_err:
logger.debug(
"compression lock cleanup after failed acquire failed: %s",
_release_err,
)
_lock_holder = None
logger.warning(
"compression lock acquisition raised unexpectedly for "
"session=%s (%s: %s) — skipping compression this cycle",
_lock_sid, type(_lock_err).__name__, _lock_err,
)
_lock_acquired = True # treat as acquired-but-unlocked; proceed
_lock_acquired = False
if not _lock_acquired:
try:
existing = _lock_db.get_compression_lock_holder(_lock_sid)
@@ -651,6 +733,17 @@ def compress_context(
_release_lock()
raise
# Capture boundary quality before session-rotation callbacks run. Built-in
# and plugin lifecycle hooks may reset per-session compressor fields while
# rebinding to the child id; the completed attempt's verdict must survive
# that rebind and be recorded only after the full boundary commits.
_compression_made_progress = bool(
getattr(agent.context_compressor, "_last_compression_made_progress", False)
)
_compression_used_fallback = bool(
getattr(agent.context_compressor, "_last_summary_fallback_used", False)
)
# If compression aborted (aux LLM failed to produce a usable summary)
# the compressor returns the input messages unchanged. Surface the
# error to the user, skip the session-rotation work entirely (no
@@ -979,8 +1072,19 @@ def compress_context(
# the full compaction boundary. Exceptions, aborts, and no-op attempts
# leave this false, so unrelated later usage cannot be charged to an
# attempt that never changed the transcript.
if getattr(agent.context_compressor, "_last_compression_made_progress", False):
agent.context_compressor._verify_compaction_cleared_threshold = True
if _compression_made_progress:
record_boundary = getattr(
type(agent.context_compressor),
"record_completed_compaction",
None,
)
if callable(record_boundary):
record_boundary(
agent.context_compressor,
used_fallback=_compression_used_fallback,
)
else:
agent.context_compressor._verify_compaction_cleared_threshold = True
# Clear the file-read dedup cache. After compression the original
# read content is summarised away — if the model re-reads the same
+53 -14
View File
@@ -128,6 +128,17 @@ _EXTRA_KEYS = frozenset({
})
def _normalize_pool_auth_type(provider: str, token: Any, auth_type: Any) -> str:
"""Infer pool auth metadata for token formats with one unambiguous meaning."""
if (
provider == "anthropic"
and isinstance(token, str)
and token.startswith("sk-ant-oat")
):
return AUTH_TYPE_OAUTH
return str(auth_type or AUTH_TYPE_API_KEY)
@dataclass
class PooledCredential:
provider: str
@@ -157,6 +168,11 @@ class PooledCredential:
def __post_init__(self):
if self.extra is None:
self.extra = {}
self.auth_type = _normalize_pool_auth_type(
self.provider,
self.access_token,
self.auth_type,
)
def __getattr__(self, name: str):
if name in _EXTRA_KEYS:
@@ -1416,6 +1432,11 @@ class CredentialPool:
entries_to_prune: List[str] = []
available: List[PooledCredential] = []
for entry in self._entries:
# Borrowed credentials persist as metadata-only references and are
# hydrated from their live source on load. A stale duplicate row
# can remain unhydrated; never lease or select it as an empty key.
if entry.auth_type == AUTH_TYPE_API_KEY and not entry.runtime_api_key:
continue
# For anthropic claude_code entries, sync from the credentials file
# before any status/refresh checks. This picks up tokens refreshed
# by other processes (Claude Code CLI, other Hermes profiles).
@@ -1732,11 +1753,15 @@ class CredentialPool:
def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool:
existing_idx = None
matching_indices = []
for idx, entry in enumerate(entries):
if entry.source == source:
existing_idx = idx
break
matching_indices.append(idx)
existing_idx = matching_indices[0] if matching_indices else None
duplicate_indices = set(matching_indices[1:])
if duplicate_indices:
entries[:] = [entry for idx, entry in enumerate(entries) if idx not in duplicate_indices]
if existing_idx is None:
payload.setdefault("id", uuid.uuid4().hex[:6])
@@ -1768,8 +1793,8 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p
# Runtime-only borrowed secret updates should refresh the in-memory
# entry without forcing auth.json churn when the disk-safe payload is
# unchanged (for example env keys with the same fingerprint).
return existing.to_dict() != updated.to_dict()
return False
return bool(duplicate_indices) or existing.to_dict() != updated.to_dict()
return bool(duplicate_indices)
def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -> bool:
@@ -2243,12 +2268,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
if _is_source_suppressed(provider, source):
continue
active_sources.add(source)
# Claude Code OAuth tokens are the only Anthropic credentials that should flow into the OAuth refresh path.
auth_type = (
AUTH_TYPE_OAUTH
if provider == "anthropic" and token.startswith("sk-ant-oat")
else AUTH_TYPE_API_KEY
)
base_url = env_url or pconfig.inference_base_url
if provider == "kimi-coding":
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
@@ -2263,7 +2282,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
env_var=env_var,
token=token,
base_url=base_url,
auth_type=auth_type,
),
)
return changed, active_sources
@@ -2391,16 +2409,37 @@ def load_pool(provider: str) -> CredentialPool:
for payload in raw_entries
)
entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]
raw_needs_auth_normalization = any(
isinstance(payload, dict)
and _normalize_pool_auth_type(
provider,
payload.get("access_token"),
payload.get("auth_type", AUTH_TYPE_API_KEY),
) != payload.get("auth_type", AUTH_TYPE_API_KEY)
for payload in raw_entries
)
if raw_needs_auth_normalization:
# A profile may be reading this provider from the global-root fallback.
# Keep that fallback read-only: only the store that owns these rows may
# rewrite them. Loading the default/root profile will heal global rows.
active_pool = _load_auth_store().get("credential_pool")
active_entries = active_pool.get(provider) if isinstance(active_pool, dict) else None
raw_needs_auth_normalization = bool(active_entries)
if provider.startswith(CUSTOM_POOL_PREFIX):
# Custom endpoint pool — seed from custom_providers config and model config
custom_changed, custom_sources = _seed_custom_pool(provider, entries)
changed = raw_needs_sanitization or custom_changed
changed = raw_needs_sanitization or raw_needs_auth_normalization or custom_changed
changed |= _prune_stale_seeded_entries(entries, custom_sources)
else:
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
env_changed, env_sources = _seed_from_env(provider, entries)
changed = raw_needs_sanitization or singleton_changed or env_changed
changed = (
raw_needs_sanitization
or raw_needs_auth_normalization
or singleton_changed
or env_changed
)
# ``load_pool()`` is a non-destructive read for env-seeded entries: a
# process missing a provider env var must not delete the persisted
# pool entry for every other process (#9331). File-backed singletons
+18
View File
@@ -807,6 +807,24 @@ def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]:
pricing["completion"] = str(float(novita_output) / 10_000 / 1_000_000)
return pricing
# DeepInfra ships pricing under ``metadata.pricing`` with $/MTok values:
# ``input_tokens``, ``output_tokens``, ``cache_read_tokens``. Convert to
# per-token strings so the generic cost machinery (usage_pricing.py)
# consumes them through the same path as OpenRouter / OpenAI.
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else None
deepinfra_pricing = metadata.get("pricing") if metadata else None
if isinstance(deepinfra_pricing, dict) and any(
k in deepinfra_pricing for k in ("input_tokens", "output_tokens", "cache_read_tokens")
):
result: Dict[str, Any] = {}
if deepinfra_pricing.get("input_tokens") is not None:
result["prompt"] = str(float(deepinfra_pricing["input_tokens"]) / 1_000_000)
if deepinfra_pricing.get("output_tokens") is not None:
result["completion"] = str(float(deepinfra_pricing["output_tokens"]) / 1_000_000)
if deepinfra_pricing.get("cache_read_tokens") is not None:
result["cache_read"] = str(float(deepinfra_pricing["cache_read_tokens"]) / 1_000_000)
return result
alias_map = {
"prompt": ("prompt", "input", "input_cost_per_token", "prompt_token_cost"),
"completion": ("completion", "output", "output_cost_per_token", "completion_token_cost"),
+2
View File
@@ -44,6 +44,8 @@ _BUILTIN_NAMES = frozenset({
"openai",
"mistral",
"xai",
"elevenlabs",
"deepinfra",
})
+1
View File
@@ -56,6 +56,7 @@ _BUILTIN_NAMES = frozenset({
"neutts",
"kittentts",
"piper",
"deepinfra",
})
+5
View File
@@ -449,6 +449,11 @@ def finalize_turn(
"estimated_cost_usd": agent.session_estimated_cost_usd,
"cost_status": agent.session_cost_status,
"cost_source": agent.session_cost_source,
# Requested service tier (from request_overrides.extra_body), for
# billing audits by callers like `hermes -z --usage-file`.
"service_tier": (
(getattr(agent, "request_overrides", {}) or {}).get("extra_body") or {}
).get("service_tier"),
"session_id": agent.session_id,
}
if agent._tool_guardrail_halt_decision is not None:
+291
View File
@@ -244,6 +244,78 @@ def save_bytes_video(
return path
_URL_VIDEO_CONTENT_TYPES = {
"video/mp4": "mp4",
"video/webm": "webm",
"video/quicktime": "mov",
"video/x-matroska": "mkv",
}
def save_url_video(
url: str,
*,
prefix: str = "video",
timeout: float = 180.0,
max_bytes: int = 200 * 1024 * 1024,
) -> Path:
"""Download a video URL and write it under ``$HERMES_HOME/cache/videos/``.
The video twin of :func:`agent.image_gen_provider.save_url_image`: several
backends (DeepInfra, FAL) return an *ephemeral* delivery URL that expires
before a downstream consumer can fetch it, so we materialise the bytes
locally at tool-completion time. Streams with a size cap.
Raises on any network / HTTP / oversize error so callers can fall back to
returning the bare URL.
"""
import requests
response = requests.get(url, timeout=timeout, stream=True)
response.raise_for_status()
content_type = (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
extension = _URL_VIDEO_CONTENT_TYPES.get(content_type)
if extension is None:
url_path = url.split("?", 1)[0].lower()
for ext in ("mp4", "webm", "mov", "mkv"):
if url_path.endswith(f".{ext}"):
extension = ext
break
if extension is None:
extension = "mp4"
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
short = uuid.uuid4().hex[:8]
path = _videos_cache_dir() / f"{prefix}_{ts}_{short}.{extension}"
bytes_written = 0
with path.open("wb") as fh:
for chunk in response.iter_content(chunk_size=256 * 1024):
if not chunk:
continue
bytes_written += len(chunk)
if bytes_written > max_bytes:
fh.close()
try:
path.unlink()
except OSError:
pass
raise ValueError(
f"Video at {url} exceeds {max_bytes // (1024 * 1024)}MB cap; refusing to cache."
)
fh.write(chunk)
if bytes_written == 0:
try:
path.unlink()
except OSError:
pass
raise ValueError(f"Video at {url} was empty (0 bytes).")
return path
def success_response(
*,
video: str,
@@ -297,3 +369,222 @@ def error_response(
"aspect_ratio": aspect_ratio,
"provider": provider,
}
# ---------------------------------------------------------------------------
# Reusable OpenAI-compatible backend
# ---------------------------------------------------------------------------
class OpenAICompatibleVideoGenProvider(VideoGenProvider):
"""Generic text/image-to-video over the OpenAI ``client.videos`` API.
DeepInfra, OpenAI/Sora, and OpenRouter all expose the same
``POST /videos`` async-job shape (``create`` → poll → ``download_content``),
so the SDK call lives here once. A concrete backend only needs to declare
its identity and credentials::
class FooVideoGenProvider(OpenAICompatibleVideoGenProvider):
name = "foo"
_env_key = "FOO_API_KEY"
_default_base_url = "https://api.foo.com/v1/openai"
def list_models(self):
return [...] # entries with an "id" key; default_model() uses [0]
``image_url`` routes to image-to-video; its absence routes to text-to-video.
Provider-specific fields (``image_url``/``negative_prompt``/``seed``) ride
in ``extra_body`` so they pass through the SDK unchanged.
"""
_env_key: str = "OPENAI_API_KEY"
_default_base_url: str = "https://api.openai.com/v1"
# Polling cadence for the async video job. The OpenAI SDK's
# ``create_and_poll`` defaults to ~1 poll/second and loops forever on a
# non-terminal status, so a multi-minute job issues hundreds of sequential
# requests and a stuck job pins its tool-executor worker thread with no way
# out. We hand-roll a bounded poll instead: a coarse interval plus a hard
# wall-clock deadline that surfaces a timeout error.
_poll_interval_s: float = 5.0
_poll_deadline_s: float = 900.0
def _api_key(self) -> str:
import os
return os.environ.get(self._env_key, "").strip()
def is_available(self) -> bool:
return bool(self._api_key())
def _create_and_poll(self, client: Any, call_kwargs: Dict[str, Any]) -> Any:
"""Create the video job and poll to completion with a hard deadline.
Replaces ``client.videos.create_and_poll`` (unbounded 1/s loop) with a
coarse interval and a wall-clock cap. Returns the terminal video object
(any status); raises :class:`TimeoutError` if the deadline passes
first.
"""
import time
video = client.videos.create(**call_kwargs)
terminal = {"completed", "succeeded", "failed", "error", "cancelled", "canceled"}
deadline = time.monotonic() + self._poll_deadline_s
while getattr(video, "status", None) not in terminal:
if time.monotonic() >= deadline:
raise TimeoutError(
f"video job {getattr(video, 'id', '?')} did not reach a terminal "
f"status within {int(self._poll_deadline_s)}s "
f"(last status={getattr(video, 'status', None)!r})"
)
time.sleep(self._poll_interval_s)
video = client.videos.retrieve(video.id)
return video
def _base_url(self) -> str:
import os
override = os.environ.get(f"{self.name.upper()}_BASE_URL", "").strip()
return override or self._default_base_url
def generate(
self,
prompt: str,
*,
model: Optional[str] = None,
image_url: Optional[str] = None,
reference_image_urls: Optional[List[str]] = None,
duration: Optional[int] = None,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
resolution: str = DEFAULT_RESOLUTION,
negative_prompt: Optional[str] = None,
audio: Optional[bool] = None,
seed: Optional[int] = None,
**kwargs: Any,
) -> Dict[str, Any]:
if not prompt or not prompt.strip():
return error_response(
error="prompt is required", error_type="invalid_request", provider=self.name
)
if not self._api_key():
return error_response(
error=f"{self._env_key} is not set",
error_type="missing_credentials",
provider=self.name,
)
try:
import openai
except ImportError:
return error_response(
error="openai Python package not installed (pip install openai)",
error_type="missing_dependency",
provider=self.name,
)
model_id = model or self.default_model()
if not model_id:
return error_response(
error=f"no {self.name} video model available (live catalog empty?)",
error_type="no_model",
provider=self.name,
)
# Provider-specific fields the OpenAI ``videos.create`` signature does
# not name natively — pass them through ``extra_body``.
extra_body = {
k: v
for k, v in {
"negative_prompt": negative_prompt,
"aspect_ratio": aspect_ratio,
"image_url": image_url, # presence ⇒ image-to-video
"seed": seed,
}.items()
if v is not None
}
call_kwargs: Dict[str, Any] = {"model": model_id, "prompt": prompt}
if duration:
call_kwargs["seconds"] = str(duration)
if resolution:
call_kwargs["size"] = resolution
if extra_body:
call_kwargs["extra_body"] = extra_body
client = openai.OpenAI(api_key=self._api_key(), base_url=self._base_url())
try:
try:
video = self._create_and_poll(client, call_kwargs)
except Exception as exc: # noqa: BLE001 - surface any SDK/API/timeout failure uniformly
logger.debug("%s video generation failed", self.name, exc_info=True)
return error_response(
error=f"{self.name} video generation failed: {exc}",
error_type="api_error",
provider=self.name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect_ratio,
)
# Terminal success status differs across backends: DeepInfra reports
# "succeeded", OpenAI/Sora reports "completed". Accept both.
status = getattr(video, "status", None)
if status not in ("completed", "succeeded"):
# ``video.error`` is a structured SDK object (pydantic
# VideoCreateError), not a string — str() it so the response
# dict stays JSON-serializable for the tool layer.
job_error = getattr(video, "error", None)
return error_response(
error=str(job_error) if job_error else f"video job ended with status={status!r}",
error_type="job_failed",
provider=self.name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect_ratio,
)
# Resolve the output. Providers expose it either as a delivery URL in
# the job's ``data`` list (DeepInfra, FAL-style) or only via the SDK
# download endpoint (OpenAI/Sora). Download the bytes and save locally
# so the caller gets a durable file — DeepInfra's delivery URLs in
# particular are short-lived. Matches plugins/image_gen/deepinfra.
url = None
for item in getattr(video, "data", None) or []:
candidate = item.get("url") if isinstance(item, dict) else getattr(item, "url", None)
if candidate:
url = candidate
break
try:
if url:
# Materialise the (often short-lived) delivery URL locally.
video_ref = str(save_url_video(url, prefix=self.name))
else:
# OpenAI/Sora style: no public URL — pull bytes via the SDK.
raw = client.videos.download_content(video.id).read()
video_ref = str(save_bytes_video(raw, prefix=self.name))
except Exception as exc: # noqa: BLE001
if url:
# Best-effort: hand back the URL rather than fail outright.
logger.debug("%s: saving video locally failed (%s); returning URL", self.name, exc)
video_ref = url
else:
return error_response(
error=f"{self.name} video job succeeded but no output could be retrieved: {exc}",
error_type="empty_response",
provider=self.name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect_ratio,
)
return success_response(
video=video_ref,
model=model_id,
prompt=prompt,
modality="image" if image_url else "text",
aspect_ratio=aspect_ratio,
duration=duration or 0,
provider=self.name,
)
finally:
close = getattr(client, "close", None)
if callable(close):
close()
+22 -6
View File
@@ -11,12 +11,15 @@ Active selection
The active provider is chosen by ``video_gen.provider`` in ``config.yaml``.
If unset, :func:`get_active_provider` applies fallback logic:
1. If exactly one provider is registered, use it.
1. If exactly one *available* provider is registered, use it.
2. Otherwise return ``None`` (the tool surfaces a helpful error pointing
the user at ``hermes tools``).
Mirrors ``agent/image_gen_registry.py`` so the two surfaces behave the
same.
same: the unconfigured fallback is filtered by ``is_available()`` so a box
that has credentials for only one backend (e.g. DeepInfra, while the
``fal``/``xai`` plugins also register unconditionally) auto-selects it
instead of returning ``None``.
"""
from __future__ import annotations
@@ -100,13 +103,26 @@ def get_active_provider() -> Optional[VideoGenProvider]:
if provider is not None:
return provider
logger.debug(
"video_gen.provider='%s' configured but not registered; falling back",
"video_gen.provider='%s' configured but not registered; failing closed",
configured,
)
return None
# Fallback: single-provider case
if len(snapshot) == 1:
return next(iter(snapshot.values()))
def _is_available_safe(p: VideoGenProvider) -> bool:
"""Wrap ``is_available()`` so a buggy provider doesn't kill resolution."""
try:
return bool(p.is_available())
except Exception as exc: # noqa: BLE001
logger.debug("video_gen provider %s.is_available() raised %s", p.name, exc)
return False
# Fallback: single *available* provider — filter by is_available() so a
# box with credentials for only one backend auto-selects it even when
# other providers (fal/xai) register unconditionally without keys.
# Mirrors agent/image_gen_registry.get_active_provider().
available = [p for p in snapshot.values() if _is_available_safe(p)]
if len(available) == 1:
return available[0]
return None
+2 -1
View File
@@ -12,7 +12,8 @@
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:build:debug": "tauri build --debug",
"typecheck": "tsc -p . --noEmit"
"typecheck": "tsc -p . --noEmit",
"check": "npm run typecheck"
},
"dependencies": {
"@nous-research/ui": "0.16.0",
+55
View File
@@ -0,0 +1,55 @@
/**
* backend-child.ts
*
* Windows-aware teardown for the desktop's managed backend child process.
*
* Node's `child.kill()` only signals the direct child. On Windows a backend
* that spawned its own grandchildren (a `hermes` REPL, a pty terminal
* session, the gateway) survives a plain SIGTERM and keeps files (e.g. the
* venv shim) locked. So on Windows we tree-kill via `forceKillProcessTree`;
* everywhere else a plain SIGTERM is correct and sufficient (POSIX has no
* mandatory locks, and the backend is not spawned detached so there's no
* process-group to negative-pid-kill).
*
* Extracted into its own dependency-free module (no electron import) so the
* SIGTERM-vs-tree-kill branching can be asserted directly with a fake child
* object and a spy `forceKillProcessTree`, instead of grepping main.ts source
* text for the function body.
*/
export interface StopBackendChildDeps {
/** Defaults to the real platform check; injectable for tests. */
isWindows?: boolean
/** Windows tree-kill implementation (real: taskkill /T /F via execFileSync). */
forceKillProcessTree: (pid: number) => void
}
export interface KillableChild {
pid?: number | null
killed?: boolean
kill: (signal: string) => void
}
/**
* Stop a managed child process, choosing the right strategy for the platform.
* No-ops silently if `child` is falsy, already killed, or the kill attempt
* throws (the process may already be gone) -- mirrors the original inline
* best-effort semantics in main.ts.
*/
export function stopBackendChild(child: KillableChild | null | undefined, deps: StopBackendChildDeps) {
if (!child || child.killed) {
return
}
const isWindows = deps.isWindows ?? process.platform === 'win32'
try {
if (isWindows && Number.isInteger(child.pid)) {
deps.forceKillProcessTree(child.pid as number)
} else {
child.kill('SIGTERM')
}
} catch {
// Already gone.
}
}
@@ -1,7 +1,6 @@
'use strict'
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
-2
View File
@@ -1,5 +1,3 @@
'use strict'
// Backend subcommand routing for the desktop-managed Hermes process.
//
// The desktop app launches its own headless backend via `hermes serve` — it
+2 -1
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import test from 'node:test'
import { test } from 'vitest'
import {
appendUniquePathEntries,
+3
View File
@@ -44,12 +44,14 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
if (!entry) {
continue
}
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
for (const part of parts) {
if (!part || seen.has(part)) {
continue
}
seen.add(part)
ordered.push(part)
}
@@ -77,6 +79,7 @@ function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatfor
if (!hermesHome) {
return hermesHome
}
const resolved = pathModule.resolve(String(hermesHome))
const parent = pathModule.dirname(resolved)
+2 -1
View File
@@ -9,7 +9,8 @@ import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { test } from 'vitest'
import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
+2 -1
View File
@@ -16,7 +16,8 @@ import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { test } from 'vitest'
import {
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
+4
View File
@@ -60,6 +60,7 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
if (done) {
return
}
done = true
clearTimeout(timer)
child.stdout.off('data', onData)
@@ -130,12 +131,14 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
if (done) {
return
}
done = true
clearTimeout(timer)
if (interval) {
clearInterval(interval)
}
child.off('exit', onExit)
child.off('error', onError)
}
@@ -171,6 +174,7 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
if (typeof interval.unref === 'function') {
interval.unref()
}
check()
})
}
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
bundledRuntimeImportCheck,
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { test } from 'vitest'
import {
buildPinArgs,
+9 -9
View File
@@ -6,7 +6,7 @@
* the renderer.
*
* Wired from electron/main.ts:
* import { runBootstrap }from './bootstrap-runner.ts'
* import { runBootstrap }from './bootstrap-runner'
* const result = await runBootstrap({
* installStamp, // INSTALL_STAMP from main.ts (may be null in dev)
* activeRoot, // ACTIVE_HERMES_ROOT
@@ -38,16 +38,10 @@ import fsp from 'node:fs/promises'
import https from 'node:https'
import path from 'node:path'
import { hiddenWindowsChildOptions } from './windows-child-options'
const IS_WINDOWS = process.platform === 'win32'
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
// Stages flagged needs_user_input=true in the manifest are skipped by the
@@ -73,6 +67,7 @@ function resolveLocalInstallScript(sourceRepoRoot) {
if (!sourceRepoRoot) {
return null
}
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
try {
@@ -96,6 +91,7 @@ function installedAgentInstallScript(hermesHome) {
if (!hermesHome) {
return null
}
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
try {
@@ -425,6 +421,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
reject(err)
})
@@ -441,6 +438,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
if (stderrBuf) {
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any)
}
resolve({ stdout, stderr, code, signal, killed } as any)
})
})
@@ -517,6 +515,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
reject(err)
})
@@ -532,6 +531,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
if (stderrBuf) {
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
}
resolve({ stdout, stderr, code, signal, killed })
})
})
@@ -11,7 +11,8 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
AT_COOKIE_VARIANTS,
@@ -6,7 +6,8 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
adoptServedDashboardToken,
@@ -10,7 +10,8 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
buildPosixCleanupScript,
@@ -106,6 +106,7 @@ function resolveRemovableAppPath(execPath, platform, env: any = {}) {
if (env.APPIMAGE) {
return env.APPIMAGE
}
// Unpacked electron-builder tree: …/linux-unpacked/hermes
const dir = p.dirname(exe)
+2 -1
View File
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { pathToFileURL } from 'node:url'
import { test } from 'vitest'
import { readDirForIpc } from './fs-read-dir'
function mkTmpDir() {
@@ -10,7 +10,8 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import { probeGatewayWebSocket } from './gateway-ws-probe'
@@ -80,6 +80,7 @@ function probeGatewayWebSocket<T>(
if (settled) {
return
}
settled = true
clearTimers()
@@ -107,6 +108,7 @@ function probeGatewayWebSocket<T>(
if (settled) {
return
}
opened = true
// Upgrade accepted. Give the server a brief window to reject the
// credential post-handshake (early close) before declaring success.
@@ -189,6 +191,7 @@ function extractErrorReason(event) {
if (event instanceof Error) {
return event.message
}
const err = event.error || event.message
if (err instanceof Error) {
+2 -1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import { resolveRenamePath } from './git-review-ops'
+18 -14
View File
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { pathToFileURL } from 'node:url'
import { test } from 'vitest'
import { gitRootForIpc } from './git-root'
function mkTmpDir() {
@@ -19,20 +20,23 @@ test('gitRootForIpc returns null for invalid and device paths', async () => {
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
})
test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
test('gitRootForIpc resolves directories files missing descendants and file URLs', async () => {
const root = mkTmpDir()
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
const gitDir = path.join(root, '.git')
const srcDir = path.join(root, 'src')
const filePath = path.join(srcDir, 'index.ts')
fs.mkdirSync(gitDir)
fs.mkdirSync(srcDir)
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
try {
const gitDir = path.join(root, '.git')
const srcDir = path.join(root, 'src')
const filePath = path.join(srcDir, 'index.ts')
fs.mkdirSync(gitDir)
fs.mkdirSync(srcDir)
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
assert.equal(await gitRootForIpc(root), root)
assert.equal(await gitRootForIpc(srcDir), root)
assert.equal(await gitRootForIpc(filePath), root)
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
assert.equal(await gitRootForIpc(root), root)
assert.equal(await gitRootForIpc(srcDir), root)
assert.equal(await gitRootForIpc(filePath), root)
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
+53 -1
View File
@@ -3,11 +3,13 @@ import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { test } from 'vitest'
import {
addWorktree,
ensureGitRepo,
listBaseBranches,
listBranches,
parseWorktrees,
sanitizeBranch,
@@ -210,3 +212,53 @@ test('addWorktree: existing default branch switches the main checkout, not .work
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBaseBranches: lists local branches and flags the default', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-branches-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
const trunk = git('branch', '--show-current')
execFileSync('git', ['branch', 'feature'], { cwd: dir })
const branches = await listBaseBranches(dir, 'git')
const names = branches.map(b => b.name).sort()
assert.deepEqual(names, [trunk, 'feature'].sort())
// No remote → all local.
assert.equal(branches.every(b => !b.isRemote), true)
// The trunk is flagged as the default.
assert.equal(branches.find(b => b.name === trunk).isDefault, true)
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBaseBranches: empty on a non-repo path', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-nonrepo-'))
try {
assert.deepEqual(await listBaseBranches(dir, 'git'), [])
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('addWorktree: base param branches off a specified local branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-add-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'staging'], { cwd: dir })
const result = await addWorktree(dir, { base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' }, 'git')
assert.equal(result.branch, 'new-from-staging')
assert.equal(git('-C', result.path, 'merge-base', 'HEAD', 'staging').length > 0, true)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
+66 -1
View File
@@ -251,7 +251,25 @@ async function addWorktree(repoPath, options, gitBin) {
const args = ['worktree', 'add', '-b', branch, dir]
if (opts.base) {
args.push(String(opts.base))
// Remote-tracking branches may be stale or missing if the user hasn't
// fetched recently. When the base is an `origin/…` ref, fetch just that
// branch so `git worktree add -b new origin/main` works against the
// latest remote commit. Local branches are used as-is.
const base = String(opts.base)
if (base.startsWith('origin/')) {
const remoteBranch = base.slice('origin/'.length)
try {
await runGit(gitBin, ['fetch', 'origin', remoteBranch], root)
} catch {
// The fetch isn't mandatory, but it would be nice to do if possible.
// If it's not possible, just use the local ref of the remote branch.
// If it doesn't exist locally, we'll get an error anyways
}
}
args.push(base)
}
try {
@@ -337,9 +355,56 @@ async function switchBranch(repoPath, branch, gitBin) {
return { branch: target }
}
// Branches the new worktree can be based on: local heads + remote-tracking
// refs. Listed most-recently-committed first; the remote's default branch
// (origin/HEAD) is flagged so the UI can preselect it. Empty on a non-repo /
// remote backend where the probe can't run.
async function listBaseBranches(repoPath, gitBin) {
let resolved
try {
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Base branch list' })
} catch {
return []
}
try {
const out = await runGit(
gitBin,
['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso)', '--sort=-committerdate', 'refs/heads', 'refs/remotes'],
resolved
)
const remoteDefault = await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], resolved)
const localDefault = await defaultBranch(gitBin, resolved)
return out
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(line => {
const [name] = line.split('\t')
return {
name,
isRemote: name.startsWith('origin/'),
// origin/HEAD when a remote exists; otherwise the local default
// (main/master/init.defaultBranch) so a no-remote repo still flags
// its trunk.
isDefault: Boolean(
(remoteDefault && name === remoteDefault) || (!remoteDefault && localDefault && name === localDefault)
)
}
})
} catch {
return []
}
}
export {
addWorktree,
ensureGitRepo,
listBaseBranches,
listBranches,
listWorktrees,
parseWorktrees,
+147 -133
View File
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { pathToFileURL } from 'node:url'
import { test } from 'vitest'
import {
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
@@ -122,171 +123,184 @@ test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
)
})
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
test('resolveReadableFileForIpc validates existence type size and sensitivity', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const textPath = path.join(tempDir, 'notes.txt')
fs.writeFileSync(textPath, 'hello world', 'utf8')
try {
const textPath = path.join(tempDir, 'notes.txt')
fs.writeFileSync(textPath, 'hello world', 'utf8')
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
baseDir: tempDir,
maxBytes: 256,
purpose: 'File preview'
})
assert.equal(fromRelative.resolvedPath, textPath)
assert.equal(fromRelative.stat.size, 11)
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
purpose: 'File preview'
})
assert.equal(fromFileUrl.resolvedPath, textPath)
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
purpose: 'File preview'
})
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
await assert.rejects(
resolveReadableFileForIpc('missing.txt', {
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
baseDir: tempDir,
purpose: 'Text preview'
}),
/file does not exist/
)
const nestedDir = path.join(tempDir, 'directory')
fs.mkdirSync(nestedDir)
await assert.rejects(
resolveReadableFileForIpc(nestedDir, {
purpose: 'Text preview'
}),
/path points to a directory/
)
const largePath = path.join(tempDir, 'large.txt')
fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
await assert.rejects(
resolveReadableFileForIpc(largePath, {
maxBytes: 8,
maxBytes: 256,
purpose: 'File preview'
}),
/file is too large/
)
})
const envPath = path.join(tempDir, '.env')
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
await assert.rejects(
resolveReadableFileForIpc(envPath, {
assert.equal(fromRelative.resolvedPath, textPath)
assert.equal(fromRelative.stat.size, 11)
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
purpose: 'File preview'
}),
/blocked for sensitive file/
)
})
const envTemplatePath = path.join(tempDir, '.env.example')
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
assert.equal(fromFileUrl.resolvedPath, textPath)
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
purpose: 'File preview'
})
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
assert.equal(envTemplate.resolvedPath, envTemplatePath)
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
purpose: 'File preview'
})
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
await assert.rejects(
resolveReadableFileForIpc('missing.txt', {
baseDir: tempDir,
purpose: 'Text preview'
}),
/file does not exist/
)
const nestedDir = path.join(tempDir, 'directory')
fs.mkdirSync(nestedDir)
await assert.rejects(
resolveReadableFileForIpc(nestedDir, {
purpose: 'Text preview'
}),
/path points to a directory/
)
const largePath = path.join(tempDir, 'large.txt')
fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
await assert.rejects(
resolveReadableFileForIpc(largePath, {
maxBytes: 8,
purpose: 'File preview'
}),
/file is too large/
)
const envPath = path.join(tempDir, '.env')
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
await assert.rejects(
resolveReadableFileForIpc(envPath, {
purpose: 'File preview'
}),
/blocked for sensitive file/
)
const envTemplatePath = path.join(tempDir, '.env.example')
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
purpose: 'File preview'
})
assert.equal(envTemplate.resolvedPath, envTemplatePath)
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
test('resolveReadableFileForIpc blocks common sensitive files', async t => {
test('resolveReadableFileForIpc blocks common sensitive files', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const sshDir = path.join(tempDir, '.ssh')
fs.mkdirSync(sshDir)
try {
const sshDir = path.join(tempDir, '.ssh')
fs.mkdirSync(sshDir)
const blockedFiles = [
path.join(tempDir, '.env'),
path.join(tempDir, '.npmrc'),
path.join(sshDir, 'id_ed25519'),
path.join(tempDir, 'cert.pem'),
path.join(tempDir, 'cert.p12'),
path.join(tempDir, 'cert.pfx')
]
const blockedFiles = [
path.join(tempDir, '.env'),
path.join(tempDir, '.npmrc'),
path.join(sshDir, 'id_ed25519'),
path.join(tempDir, 'cert.pem'),
path.join(tempDir, 'cert.p12'),
path.join(tempDir, 'cert.pfx')
]
for (const filePath of blockedFiles) {
fs.writeFileSync(filePath, 'secret', 'utf8')
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
for (const filePath of blockedFiles) {
fs.writeFileSync(filePath, 'secret', 'utf8')
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
}
const allowed = path.join(tempDir, '.env.example')
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
const allowed = path.join(tempDir, '.env.example')
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
})
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => {
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const envPath = path.join(tempDir, '.env')
const linkPath = path.join(tempDir, 'safe-name.txt')
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
try {
fs.symlinkSync(envPath, linkPath, 'file')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
const envPath = path.join(tempDir, '.env')
const linkPath = path.join(tempDir, 'safe-name.txt')
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
return
try {
fs.symlinkSync(envPath, linkPath, 'file')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
// symlink creation is not permitted on this platform — skip
return
}
throw error
}
throw error
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
})
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const directory = path.join(tempDir, 'project')
const filePath = path.join(tempDir, 'file.txt')
fs.mkdirSync(directory)
fs.writeFileSync(filePath, 'not a directory', 'utf8')
const resolved = await resolveDirectoryForIpc(directory)
assert.equal(resolved.resolvedPath, directory)
assert.equal(resolved.stat.isDirectory(), true)
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
})
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const directory = path.join(tempDir, 'actual-project')
const linkPath = path.join(tempDir, 'linked-project')
fs.mkdirSync(directory)
try {
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
const directory = path.join(tempDir, 'project')
const filePath = path.join(tempDir, 'file.txt')
fs.mkdirSync(directory)
fs.writeFileSync(filePath, 'not a directory', 'utf8')
return
const resolved = await resolveDirectoryForIpc(directory)
assert.equal(resolved.resolvedPath, directory)
assert.equal(resolved.stat.isDirectory(), true)
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
try {
const directory = path.join(tempDir, 'actual-project')
const linkPath = path.join(tempDir, 'linked-project')
fs.mkdirSync(directory)
try {
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
// directory symlink creation is not permitted on this platform — skip
return
}
throw error
}
throw error
const resolved = await resolveDirectoryForIpc(linkPath)
assert.equal(resolved.resolvedPath, linkPath)
assert.equal(resolved.stat.isDirectory(), true)
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
const resolved = await resolveDirectoryForIpc(linkPath)
assert.equal(resolved.resolvedPath, linkPath)
assert.equal(resolved.stat.isDirectory(), true)
})
+1
View File
@@ -112,6 +112,7 @@ function sensitiveFileBlockReason(filePath) {
function ipcPathError(code: any, message: string): Error & { code: any } {
const error = new Error(message) as Error & { code: any }
;(error as any).code = code
return error
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
createLinkTitleWindow,
@@ -55,6 +55,7 @@ export function readLinkTitleWindowTitle(window) {
if (!window || window.isDestroyed()) {
return ''
}
const contents = window.webContents
if (!contents || contents.isDestroyed()) {
+131 -184
View File
@@ -1,5 +1,4 @@
type Method = string
import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process'
import { execFile, execFileSync, spawn } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
@@ -30,6 +29,7 @@ import {
} from 'electron'
import nodePty from 'node-pty'
import { stopBackendChild as stopBackendChildImpl } from './backend-child'
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
import { canImportHermesCli, verifyHermesCli } from './backend-probes'
@@ -84,7 +84,7 @@ import {
reviewUnstage
} from './git-review-ops'
import { gitRootForIpc } from './git-root'
import { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops'
import { addWorktree, listBaseBranches, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops'
import {
DATA_URL_READ_MAX_BYTES,
DEFAULT_FETCH_TIMEOUT_MS,
@@ -96,6 +96,7 @@ import {
} from './hardening'
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
import {
buildSessionWindowUrl,
chatWindowWebPreferences,
@@ -125,6 +126,8 @@ import {
MIN_HEIGHT as WINDOW_MIN_HEIGHT,
MIN_WIDTH as WINDOW_MIN_WIDTH
} from './window-state'
import { hiddenWindowsChildOptions } from './windows-child-options'
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
import { readWindowsUserEnvVar } from './windows-user-env'
import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd'
import { readWslWindowsClipboardImage } from './wsl-clipboard-image'
@@ -152,14 +155,6 @@ const APP_ROOT = app.getAppPath()
// Dev (`npm run dev`) and prod both load the esbuild output from dist/.
const PRELOAD_PATH = path.join(APP_ROOT, 'dist', 'electron-preload.js')
function hiddenWindowsChildOptions(options: any = {}): ExecFileSyncOptionsWithStringEncoding {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options as any
}
return { ...options, windowsHide: true } as any
}
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
// compositor flicker — accelerated layers can't be presented cleanly over the
// wire, so the window flashes during scroll/streaming/animation. Local
@@ -870,6 +865,7 @@ function planDesktopLogRotation(size) {
if (size < DESKTOP_LOG_MAX_BYTES) {
return []
}
const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1))
// Pathological boot-loop log: reclaim live + every backup outright.
@@ -937,6 +933,7 @@ function flushDesktopLogBufferSync() {
if (!desktopLogBuffer) {
return
}
const chunk = desktopLogBuffer
desktopLogBuffer = ''
@@ -953,6 +950,7 @@ function flushDesktopLogBufferAsync() {
if (!desktopLogBuffer) {
return desktopLogFlushPromise
}
const chunk = desktopLogBuffer
desktopLogBuffer = ''
@@ -973,6 +971,7 @@ function scheduleDesktopLogFlush() {
if (desktopLogFlushTimer) {
return
}
desktopLogFlushTimer = setTimeout(() => {
desktopLogFlushTimer = null
void flushDesktopLogBufferAsync()
@@ -985,6 +984,7 @@ function rememberLog(chunk) {
if (!text) {
return
}
const lines = text.split(/\r?\n/).map(line => `[hermes] ${line}`)
hermesLog.push(...lines)
@@ -1183,11 +1183,13 @@ function broadcastBootProgress() {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) {
return
}
webContents.send('hermes:boot-progress', bootProgressState)
}
@@ -1267,11 +1269,13 @@ function broadcastBootstrapEvent(ev) {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) {
return
}
webContents.send('hermes:bootstrap:event', ev)
}
@@ -1421,9 +1425,7 @@ function findOnPath(command) {
// shell-script shim named `hermes` — must not shadow `hermes.cmd`/`hermes.exe`.
// The empty entry is kept LAST so callers that already include the extension
// (py.exe, pwsh.exe, powershell.exe) still resolve.
const extensions = IS_WINDOWS
? [...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
: ['']
const extensions = buildPathExtCandidates(process.env.PATHEXT, IS_WINDOWS)
for (const entry of pathEntries) {
for (const extension of extensions) {
@@ -1443,71 +1445,21 @@ function isCommandScript(command) {
}
function unwrapWindowsVenvHermesCommand(command, backendArgs) {
if (!IS_WINDOWS || !command || isCommandScript(command)) {
return null
}
const resolved = path.resolve(String(command))
if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) {
return null
}
const scriptsDir = path.dirname(resolved)
if (path.basename(scriptsDir).toLowerCase() !== 'scripts') {
return null
}
const venvRoot = path.dirname(scriptsDir)
const python = getVenvPython(venvRoot)
if (!fileExists(python)) {
return null
}
const root = path.dirname(venvRoot)
// Smoke-test the venv interpreter before trusting it. A venv whose update
// died mid-`pip install` still has python.exe + hermes.exe on disk, but the
// backend dies on its first import (e.g. ModuleNotFoundError: dotenv) before
// the gateway ever binds. Returning it here also BYPASSED the caller's
// `--version` probe, so Retry/"Repair install" re-resolved the same broken
// venv forever instead of falling through to the bootstrap installer.
// Mirror isActiveRuntimeUsable(): probe with the checkout on PYTHONPATH so a
// healthy source-tree venv passes.
if (
!canImportHermesCli(python, {
env: {
PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH]
.filter(Boolean)
.join(path.delimiter)
}
})
) {
rememberLog(
`Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.`
)
return null
}
return {
label: `existing Hermes Python at ${python}`,
command: python,
args: ['-m', 'hermes_cli.main', ...backendArgs],
bootstrap: false,
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
venvRoot
}),
kind: 'python',
// Surfaced so backendSupportsServe() can read this runtime's source for the
// `serve` capability check instead of falling back to a heavyweight probe.
root,
shell: false
}
return resolveVenvHermesCommand(command, backendArgs, {
isWindows: IS_WINDOWS,
isCommandScript,
fileExists,
directoryExists,
canImportHermesCli,
getVenvPython,
getVenvSitePackagesEntries,
buildDesktopBackendEnv,
hermesHome: HERMES_HOME,
resolvePath: (...segments) => path.resolve(...segments),
dirname: p => path.dirname(p),
basename: p => path.basename(p),
rememberLog
})
}
// Does the resolved runtime understand the `serve` subcommand? The desktop
@@ -1526,6 +1478,7 @@ function backendSupportsServe(backend) {
if (!backend || !backend.command) {
return true
}
const key = `${backend.command}::${backend.root || ''}`
if (_serveSupportCache.has(key)) {
@@ -1855,45 +1808,6 @@ function getVenvPython(venvRoot) {
// normal HERMES_DASHBOARD_READY stdout line and no ready-file side channel is
// needed.
function getVenvSitePackagesEntries(venvRoot) {
const entries = []
if (!venvRoot) {
return entries
}
if (IS_WINDOWS) {
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
if (directoryExists(sitePackages)) {
entries.push(sitePackages)
}
return entries
}
const version = (() => {
try {
const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im)
return match ? match[1].trim() : null
} catch {
return null
}
})()
if (version) {
const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages')
if (directoryExists(sitePackages)) {
entries.push(sitePackages)
}
}
return entries
}
function makeDashboardReadyFile() {
const dir = path.join(app.getPath('userData'), 'backend-ready')
fs.mkdirSync(dir, { recursive: true })
@@ -2326,6 +2240,7 @@ function isShimLocked(shimPath) {
if (!IS_WINDOWS) {
return false
}
let fd
try {
@@ -2463,6 +2378,7 @@ async function releaseBackendLock(updateRoot, tag) {
for (const pid of stragglers) {
forceKillProcessTree(pid)
}
await new Promise(r => setTimeout(r, 300))
}
@@ -2662,7 +2578,7 @@ async function handOffWindowsBootstrapRecovery(reason) {
const haveRealInstall =
fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
const updaterArgs = chooseUpdaterArgs(haveRealInstall, branch)
await releaseBackendLockForUpdate(updateRoot)
@@ -2757,6 +2673,7 @@ function runningAppBundle() {
if (!IS_MAC) {
return null
}
let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS
for (let i = 0; i < 2; i++) {
@@ -3183,6 +3100,7 @@ function resolveRendererIndex() {
if (found) {
return found
}
// Nothing on disk. A packaged build with no renderer bundle blank-pages with
// a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause
// and the fix before Electron loads the missing file.
@@ -3231,6 +3149,7 @@ function resolveHermesCwd() {
if (!candidate) {
continue
}
const resolved = path.resolve(String(candidate))
if (isPackagedInstallPath(resolved)) {
@@ -3755,6 +3674,7 @@ function fetchJson(url, token, options: any = {}) {
if (body) {
req.write(body)
}
req.end()
})
}
@@ -3844,6 +3764,7 @@ function fetchPublicJson(url, options: any = {}) {
if (body) {
req.write(body)
}
req.end()
})
}
@@ -3961,6 +3882,7 @@ function cacheTitle(key, title) {
if (titleCache.size >= TITLE_CACHE_LIMIT) {
titleCache.delete(titleCache.keys().next().value)
}
titleCache.set(key, title)
}
@@ -4015,6 +3937,7 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> {
if (bytes >= TITLE_BYTE_BUDGET) {
return
}
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
const remaining = TITLE_BYTE_BUDGET - bytes
const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer
@@ -4027,6 +3950,7 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> {
if (!chunks.length) {
return resolve('')
}
resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8')))
})
})
@@ -4036,6 +3960,7 @@ function getLinkTitleSession() {
if (linkTitleSession || !app.isReady()) {
return linkTitleSession
}
linkTitleSession = session.fromPartition('hermes:link-titles', { cache: false })
linkTitleSession.webRequest.onBeforeRequest((details, callback) => {
callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) })
@@ -4078,6 +4003,7 @@ function runRenderTitleJob(rawUrl) {
if (settled) {
return
}
settled = true
if (hardTimer) {
@@ -4087,6 +4013,7 @@ function runRenderTitleJob(rawUrl) {
if (graceTimer) {
clearTimeout(graceTimer)
}
const value = (title || '').replace(/\s+/g, ' ').trim()
try {
@@ -4112,6 +4039,7 @@ function runRenderTitleJob(rawUrl) {
if (graceTimer) {
clearTimeout(graceTimer)
}
graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS)
}
@@ -4193,6 +4121,7 @@ async function resourceBufferFromUrl(rawUrl) {
if (!match) {
throw new Error('Invalid data URL')
}
const mimeType = match[1] || 'application/octet-stream'
const encoded = match[3] || ''
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
@@ -4241,6 +4170,7 @@ async function copyImageFromUrl(rawUrl) {
if (image.isEmpty()) {
throw new Error('Could not read image')
}
clipboard.writeImage(image)
}
@@ -4256,6 +4186,7 @@ async function saveImageFromUrl(rawUrl) {
if (result.canceled || !result.filePath) {
return false
}
await fs.promises.writeFile(result.filePath, buffer)
return true
@@ -4390,11 +4321,13 @@ function sendPreviewFileChanged(payload) {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) {
return
}
webContents.send('hermes:preview-file-changed', payload)
}
@@ -4415,12 +4348,14 @@ async function watchPreviewFile(rawUrl) {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
timer = null
if (!fileExists(filePath)) {
return
}
sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() })
}, PREVIEW_WATCH_DEBOUNCE_MS)
})
@@ -4430,6 +4365,7 @@ async function watchPreviewFile(rawUrl) {
if (timer) {
clearTimeout(timer)
}
watcher.close()
}
})
@@ -4504,11 +4440,13 @@ function sendBackendExit(payload) {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) {
return
}
webContents.send('hermes:backend-exit', payload)
}
@@ -4516,11 +4454,13 @@ function sendClosePreviewRequested() {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) {
return
}
webContents.send('hermes:close-preview-requested')
}
@@ -4531,11 +4471,13 @@ function sendPowerResume() {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) {
return
}
webContents.send('hermes:power-resume')
}
@@ -4545,6 +4487,7 @@ function registerPowerResumeListeners() {
if (powerResumeRegistered) {
return
}
powerResumeRegistered = true
try {
@@ -4566,16 +4509,19 @@ function sendOpenUpdatesRequested() {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) {
return
}
webContents.send('hermes:open-updates')
if (!mainWindow.isVisible()) {
mainWindow.show()
}
mainWindow.focus()
}
@@ -4583,11 +4529,13 @@ function sendWindowStateChanged(nextIsFullscreen?: boolean) {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) {
return
}
const state = getWindowState()
if (typeof nextIsFullscreen === 'boolean') {
@@ -4732,6 +4680,7 @@ function installDevToolsShortcut(window) {
if (!isInspectShortcut) {
return
}
event.preventDefault()
toggleDevTools(window)
})
@@ -4756,22 +4705,22 @@ function installPreviewShortcut(window) {
// process owns setZoomLevel, so we mirror each change into localStorage and
// read it back on did-finish-load to re-apply after reloads or crash recovery.
import {
clampZoomLevel,
applyZoomLevel,
installZoomReassertOnWindowEvents,
percentToZoomLevel,
ZOOM_STORAGE_KEY,
zoomLevelToPercent
zoomLevelToPercent,
zoomWiringForWindowKind
} from './zoom'
function setAndPersistZoomLevel(window, zoomLevel) {
if (!window || window.isDestroyed()) {
return
}
const next = clampZoomLevel(zoomLevel)
window.webContents.setZoomLevel(next)
// Keep any open settings UI in sync, including changes made via the
// keyboard shortcuts or the View menu.
window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) })
// Apply + notify in one funnel so the settings UI stays in sync, including
// changes made via the keyboard shortcuts or the View menu.
const next = applyZoomLevel(window.webContents, zoomLevel)
window.webContents
.executeJavaScript(
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
@@ -4783,6 +4732,7 @@ function restorePersistedZoomLevel(window) {
if (!window || window.isDestroyed()) {
return
}
window.webContents
.executeJavaScript(
`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`
@@ -4791,8 +4741,10 @@ function restorePersistedZoomLevel(window) {
if (stored == null || !window || window.isDestroyed()) {
return
}
const level = clampZoomLevel(Number(stored))
window.webContents.setZoomLevel(level)
// Notify the renderer too — otherwise the Appearance UI Scale control
// can stay stuck at 100% even though the window zoom was restored.
applyZoomLevel(window.webContents, Number(stored))
})
.catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))
}
@@ -4867,6 +4819,7 @@ function installContextMenu(window) {
if (template.length) {
template.push({ type: 'separator' })
}
template.push(
{
label: 'Open Link',
@@ -5020,6 +4973,7 @@ function getOauthSession() {
if (oauthSession || !app.isReady()) {
return oauthSession
}
oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION)
return oauthSession
@@ -5035,6 +4989,7 @@ async function hasOauthSessionCookie(baseUrl) {
if (!sess) {
return false
}
const parsed = new URL(baseUrl)
try {
@@ -5067,6 +5022,7 @@ async function hasLiveOauthSession(baseUrl) {
if (!sess) {
return false
}
const parsed = new URL(baseUrl)
try {
@@ -5149,6 +5105,7 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
if (settled) {
return
}
settled = true
if (pollTimer) {
@@ -5318,6 +5275,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
if (timedOut) {
return
}
clearTimeout(timer)
const text = Buffer.concat(chunks).toString('utf8')
const statusCode = res.statusCode || 500
@@ -5356,6 +5314,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
if (timedOut) {
return
}
clearTimeout(timer)
reject(error)
})
@@ -5363,6 +5322,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
if (body) {
request.write(body)
}
request.end()
})
}
@@ -5768,6 +5728,7 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) {
} = {
mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local'
}
const url = String(entry.url || '').trim()
if (url) {
@@ -6169,7 +6130,7 @@ async function fetchJsonForProfile(profile, path) {
}
// Issue an arbitrary method against a profile's resolved backend, parsed JSON.
async function requestJsonForProfile(profile: string, path: string, method: Method, body?: string) {
async function requestJsonForProfile(profile: string, path: string, method: string, body?: string) {
const conn = await ensureBackend(profile)
const url = `${conn.baseUrl}${path}`
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
@@ -6322,19 +6283,7 @@ function resetBootProgressForReconnect() {
}
function stopBackendChild(child) {
if (!child || child.killed) {
return
}
try {
if (IS_WINDOWS && Number.isInteger(child.pid)) {
forceKillProcessTree(child.pid)
} else {
child.kill('SIGTERM')
}
} catch {
// Already gone.
}
stopBackendChildImpl(child, { forceKillProcessTree, isWindows: IS_WINDOWS })
}
// Soft gateway-mode apply: tear down the primary without resetting boot UI or
@@ -6469,6 +6418,7 @@ function touchPoolBackend(profile) {
if (!key) {
return
}
const entry = backendPool.get(key)
if (entry) {
@@ -6484,6 +6434,7 @@ function evictLruPoolBackends(keep) {
if (backendPool.size <= keep) {
return
}
const now = Date.now()
const evictable = [...backendPool.entries()]
@@ -6496,6 +6447,7 @@ function evictLruPoolBackends(keep) {
if (removable <= 0) {
break
}
rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`)
stopPoolBackend(profile)
removable -= 1
@@ -6506,6 +6458,7 @@ function startPoolIdleReaper() {
if (poolIdleReaper) {
return
}
poolIdleReaper = setInterval(() => {
const now = Date.now()
@@ -6658,6 +6611,7 @@ function stopPoolBackend(profile) {
if (!entry) {
return
}
backendPool.delete(profile)
stopBackendChild(entry.process)
}
@@ -6668,6 +6622,7 @@ async function teardownPoolBackendAndWait(profile) {
if (!entry) {
return
}
backendPool.delete(profile)
stopBackendChild(entry.process)
@@ -6681,59 +6636,38 @@ function stopAllPoolBackends() {
}
}
function profileNameFromDeleteRequest(request) {
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
return null
}
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
if (!match) {
return null
}
let raw = ''
try {
raw = decodeURIComponent(match[1])
} catch {
return null
}
const name = raw.trim()
if (!name) {
return null
}
if (name.toLowerCase() === 'default') {
return 'default'
}
return name.toLowerCase()
}
// Returns the profile name whose backend was torn down, or null when the
// request is not a profile-delete. The caller uses this to skip ensureBackend
// for the just-torn-down profile — otherwise ensureBackend respawns a pool
// backend whose ensure_hermes_home() recreates the deleted profile directory.
//
// The routing *decision* (which branch fires, what profile name gets
// returned) lives in the pure decideProfileDeleteAction() in
// profile-delete-routing.ts; this function only performs the side effects
// that decision calls for.
async function prepareProfileDeleteRequest(request) {
const profile = profileNameFromDeleteRequest(request)
if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
const decision = decideProfileDeleteAction(profile, {
isDefaultProfile: p => p === 'default',
isValidProfileName: p => PROFILE_NAME_RE.test(p),
primaryProfileKey
})
if (decision.action === 'noop') {
return null
}
if (profile === primaryProfileKey()) {
if (decision.action === 'teardown-primary') {
writeActiveDesktopProfile('default')
await teardownPrimaryBackendAndWait()
return profile
return decision.profile
}
await teardownPoolBackendAndWait(profile)
await teardownPoolBackendAndWait(decision.profile)
return profile
return decision.profile
}
async function startHermes() {
@@ -7014,6 +6948,7 @@ function focusWindow(win) {
if (!win.isVisible()) {
win.show()
}
win.focus()
}
@@ -7060,7 +6995,7 @@ function spawnSecondaryWindow({
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
wireCommonWindowHandlers(win)
wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat'))
win.loadURL(
buildSessionWindowUrl(sessionId, {
@@ -7170,9 +7105,9 @@ function spawnPetOverlayWindow(bounds) {
// Not supported everywhere — best effort.
}
// Pet overlay opts out of global UI zoom (see wireCommonWindowHandlers): it
// Pet overlay opts out of global UI zoom (see zoomWiringForWindowKind): it
// owns its window-fit + scale, and inheriting zoom would crop the sprite.
wireCommonWindowHandlers(win, { zoom: false })
wireCommonWindowHandlers(win, zoomWiringForWindowKind('petOverlay'))
win.once('ready-to-show', () => {
if (!win.isDestroyed()) {
@@ -7304,7 +7239,7 @@ function createWindow() {
// window-all-closed from quitting on Windows/Linux).
mainWindow.on('closed', () => closePetOverlay())
wireCommonWindowHandlers(mainWindow)
wireCommonWindowHandlers(mainWindow, zoomWiringForWindowKind('chat'))
mainWindow.webContents.on('render-process-gone', (_event, details) => {
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
@@ -7451,6 +7386,7 @@ ipcMain.on('hermes:zoom:set-percent', (event, percent) => {
if (!window || window.isDestroyed()) {
return
}
setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent)))
})
@@ -7918,7 +7854,7 @@ ipcMain.handle('hermes:api', async (_event, request) => {
// backend instead of spawning a fresh pool backend. A freshly spawned
// backend calls ensure_hermes_home() which recreates the profile directory,
// defeating the deletion and leaving a zombie process.
const routeProfile = tornDownProfile ? null : profile
const routeProfile = resolveRouteProfile(tornDownProfile, profile)
const connection = await ensureBackend(routeProfile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
@@ -7952,6 +7888,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
if (!Notification.isSupported()) {
return false
}
// Action buttons render only on signed macOS builds; elsewhere they're dropped
// and the body click still works.
const actions = Array.isArray(payload?.actions) ? payload.actions : []
@@ -7967,6 +7904,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
focusWindow(mainWindow)
if (payload?.sessionId) {
@@ -7977,6 +7915,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
const action = actions[index]
if (action?.id) {
@@ -8527,6 +8466,10 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) =>
listBaseBranches(repoPath, resolveGitBinary())
)
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail
// hides cleanly rather than erroring.
@@ -8774,6 +8717,7 @@ async function getUninstallSummary() {
if (settled) {
return
}
settled = true
resolve(value)
}
@@ -8968,6 +8912,7 @@ function handleDeepLink(url) {
if (!url || typeof url !== 'string') {
return
}
let parsed
try {
@@ -8997,6 +8942,7 @@ function handleDeepLink(url) {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.focus()
mainWindow.webContents.send('hermes:deep-link', payload)
rememberLog(`[deeplink] delivered ${kind}/${name}`)
@@ -9053,6 +8999,7 @@ if (!_gotSingleInstanceLock) {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.focus()
}
})
@@ -5,7 +5,8 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
@@ -1,33 +0,0 @@
/**
* Regression coverage for the OAuth-session Electron net.request path.
*
* Electron net rejects manual Content-Length/Host headers with
* net::ERR_INVALID_ARGUMENT. Node HTTP helpers may still set Content-Length;
* this guard is scoped to fetchJsonViaOauthSession only.
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
function extractFetchJsonViaOauthSession() {
const start = source.indexOf('function fetchJsonViaOauthSession')
const end = source.indexOf('// Mint a single-use WS ticket', start)
assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist')
assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist')
return source.slice(start, end)
}
test('OAuth Electron net request does not set forbidden Content-Length header', () => {
const fn = extractFetchJsonViaOauthSession()
assert.match(fn, /electronNet\.request/)
assert.doesNotMatch(fn, /setHeader\(['"]Content-Length['"]/)
assert.match(fn, /request\.write\(body\)/)
})
+1
View File
@@ -117,6 +117,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath),
baseBranchList: repoPath => ipcRenderer.invoke('hermes:git:baseBranchList', repoPath),
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
@@ -1,62 +0,0 @@
'use strict'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
const ELECTRON_DIR = import.meta.dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
}
// ---------------------------------------------------------------------------
// prepareProfileDeleteRequest must return the torn-down profile name so the
// caller can skip ensureBackend for that profile (issue #52279).
// ---------------------------------------------------------------------------
test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
const source = readElectronFile('main.ts')
// Locate the function definition and its closing brace.
const fnStart = source.indexOf('async function prepareProfileDeleteRequest(')
assert.notEqual(fnStart, -1, 'prepareProfileDeleteRequest function not found')
// The function must contain "return profile" (pool and primary paths).
const fnBody = source.slice(fnStart, fnStart + 800)
const returnProfileCount = (fnBody.match(/return profile/g) || []).length
assert.ok(
returnProfileCount >= 2,
`expected at least 2 "return profile" statements (primary + pool paths), found ${returnProfileCount}`
)
// The early-exit guard must return null (not void/undefined).
assert.match(fnBody, /return null/, 'early-exit guard should return null, not undefined')
})
test('hermes:api handler routes profile-delete requests to the primary backend', () => {
const source = readElectronFile('main.ts')
// The handler must capture prepareProfileDeleteRequest's return value.
assert.match(
source,
/const tornDownProfile = await prepareProfileDeleteRequest\(request\)/,
'handler should capture the return value of prepareProfileDeleteRequest'
)
// The handler must use the return value to skip ensureBackend for the
// torn-down profile, routing to the primary (null) instead.
assert.match(
source,
/const routeProfile = tornDownProfile \? null : profile/,
'handler should route to primary backend when a profile was just torn down'
)
// ensureBackend must be called with the conditional route profile.
assert.match(
source,
/const connection = await ensureBackend\(routeProfile\)/,
'handler should pass routeProfile (not raw profile) to ensureBackend'
)
})
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
// ---------------------------------------------------------------------------
// profileNameFromDeleteRequest
// ---------------------------------------------------------------------------
test('profileNameFromDeleteRequest parses a DELETE /api/profiles/<name> path', () => {
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/worker' }), 'worker')
})
test('profileNameFromDeleteRequest lowercases the profile name', () => {
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/Worker' }), 'worker')
})
test('profileNameFromDeleteRequest returns null for non-DELETE methods', () => {
assert.equal(profileNameFromDeleteRequest({ method: 'GET', path: '/api/profiles/worker' }), null)
})
test('profileNameFromDeleteRequest returns null when the path does not match', () => {
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/sessions' }), null)
})
test('profileNameFromDeleteRequest returns null for an empty/whitespace name', () => {
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/%20' }), null)
})
test('profileNameFromDeleteRequest returns null for an undecodable path segment', () => {
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/%E0%A4%A' }), null)
})
// ---------------------------------------------------------------------------
// decideProfileDeleteAction
// ---------------------------------------------------------------------------
const deps = {
isDefaultProfile: p => p === 'default',
isValidProfileName: p => /^[a-z0-9][a-z0-9_-]{0,63}$/.test(p),
primaryProfileKey: () => 'primary-profile'
}
test('decideProfileDeleteAction is a noop for the default profile', () => {
assert.deepEqual(decideProfileDeleteAction('default', deps), { action: 'noop', profile: null })
})
test('decideProfileDeleteAction is a noop for null (no profile parsed)', () => {
assert.deepEqual(decideProfileDeleteAction(null, deps), { action: 'noop', profile: null })
})
test('decideProfileDeleteAction is a noop for an invalid profile name', () => {
assert.deepEqual(decideProfileDeleteAction('Not Valid!', deps), { action: 'noop', profile: null })
})
test('decideProfileDeleteAction tears down the primary backend for the primary profile', () => {
assert.deepEqual(decideProfileDeleteAction('primary-profile', deps), {
action: 'teardown-primary',
profile: 'primary-profile'
})
})
test('decideProfileDeleteAction tears down the pool backend for any other valid profile', () => {
assert.deepEqual(decideProfileDeleteAction('worker', deps), { action: 'teardown-pool', profile: 'worker' })
})
// ---------------------------------------------------------------------------
// resolveRouteProfile
// ---------------------------------------------------------------------------
test('resolveRouteProfile routes to the primary backend (null) when a profile was torn down', () => {
assert.equal(resolveRouteProfile('worker', 'other-profile'), null)
})
test('resolveRouteProfile passes the requested profile through when nothing was torn down', () => {
assert.equal(resolveRouteProfile(null, 'other-profile'), 'other-profile')
})
test('resolveRouteProfile passes through undefined when nothing was torn down and no profile was requested', () => {
assert.equal(resolveRouteProfile(null, undefined), undefined)
})
@@ -0,0 +1,95 @@
// Profile-delete routing logic for the `hermes:api` IPC handler.
//
// When the renderer issues DELETE /api/profiles/<name>, the handler must
// tear down that profile's backend (primary window backend or pool backend)
// and then route the *next* request away from the just-deleted profile's
// pool backend -- spawning a fresh one would call ensure_hermes_home() and
// recreate the profile directory the delete just removed, leaving a zombie
// process behind (issue #52279).
//
// These helpers are pure so they can be unit-tested without Electron.
/**
* Parse a `hermes:api` request into the profile name a DELETE targets, or
* null when the request is not a profile-delete at all (wrong method, wrong
* path, empty/invalid name).
*/
export function profileNameFromDeleteRequest(request) {
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
return null
}
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
if (!match) {
return null
}
let raw = ''
try {
raw = decodeURIComponent(match[1])
} catch {
return null
}
const name = raw.trim()
if (!name) {
return null
}
if (name.toLowerCase() === 'default') {
return 'default'
}
return name.toLowerCase()
}
export type ProfileDeleteAction = 'noop' | 'teardown-primary' | 'teardown-pool'
export interface ProfileDeleteDecision {
action: ProfileDeleteAction
profile: string | null
}
export interface ProfileDeleteDecisionDeps {
isDefaultProfile: (profile: string) => boolean
isValidProfileName: (profile: string) => boolean
primaryProfileKey: () => string
}
/**
* Pure decision logic for prepareProfileDeleteRequest: given the parsed
* profile name (or null), decide which side-effecting branch the caller
* should take and what profile name it should ultimately report as
* torn-down. No I/O, no async -- the caller performs the actual teardown
* based on `action`.
*/
export function decideProfileDeleteAction(
profile: string | null,
deps: ProfileDeleteDecisionDeps
): ProfileDeleteDecision {
if (!profile || deps.isDefaultProfile(profile) || !deps.isValidProfileName(profile)) {
return { action: 'noop', profile: null }
}
if (profile === deps.primaryProfileKey()) {
return { action: 'teardown-primary', profile }
}
return { action: 'teardown-pool', profile }
}
/**
* Route the next `hermes:api` request away from the primary/window backend
* whenever a profile was just torn down -- otherwise ensureBackend would
* spawn a fresh pool backend for the deleted profile, whose
* ensure_hermes_home() recreates the directory the delete just removed.
*/
export function resolveRouteProfile(
tornDownProfile: string | null,
profile: string | undefined
): string | null | undefined {
return tornDownProfile ? null : profile
}
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
MACOS_TAHOE_DARWIN_MAJOR,
+2 -1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import { resolveBehindCount, shouldCountCommits } from './update-count'
+4 -1
View File
@@ -14,10 +14,11 @@
import fs from 'fs'
import assert from 'node:assert/strict'
import test from 'node:test'
import os from 'os'
import path from 'path'
import { test } from 'vitest'
import {
isPidAlive,
markerPath,
@@ -40,6 +41,7 @@ const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" =
const DEAD: typeof process.kill = () => {
const err = new Error('no such process')
;(err as any).code = 'ESRCH'
throw err
}
@@ -93,6 +95,7 @@ test('isPidAlive: own pid is alive, impossible pid is dead', () => {
test('isPidAlive: EPERM counts as alive (process owned by another user)', () => {
const eperm = () => {
const err = new Error('operation not permitted')
;(err as any).code = 'EPERM'
throw err
}
+2 -1
View File
@@ -13,7 +13,8 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild'
@@ -22,7 +22,8 @@ import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { test } from 'vitest'
import {
buildRelaunchScript,
+2
View File
@@ -63,6 +63,7 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) {
if (!execPath || !updateRoot) {
return null
}
const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release')
const unpacked = path.join(releaseDir, unpackedDirName(platform))
const normalizedExec = path.resolve(String(execPath))
@@ -116,6 +117,7 @@ function sandboxPreflight(unpackedDir, statSync) {
if (!unpackedDir) {
return { ok: false, reason: 'no-unpacked-dir', path: null }
}
const sandboxPath = path.join(unpackedDir, 'chrome-sandbox')
let st
+2 -1
View File
@@ -16,7 +16,8 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
canonicalGitHubRemote,
+1
View File
@@ -22,6 +22,7 @@ function canonicalGitHubRemote(url) {
if (!url) {
return ''
}
let value = String(url).trim()
if (value.startsWith('git@github.com:')) {
@@ -1,5 +1,6 @@
import assert from 'node:assert'
import test from 'node:test'
import { test } from 'vitest'
import { __testing, extractThemes, readCentralDirectory } from './vscode-marketplace'
+13 -8
View File
@@ -5,7 +5,8 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { test, vi } from 'vitest'
import {
computeWindowOptions,
@@ -118,8 +119,8 @@ test('computeWindowOptions does not clamp when displays are unknown', () => {
// ─── debounce ──────────────────────────────────────────────────────────────
test('debounce coalesces a burst into one trailing run', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
test('debounce coalesces a burst into one trailing run', () => {
vi.useFakeTimers()
let calls = 0
const d = debounce(() => {
@@ -130,14 +131,16 @@ test('debounce coalesces a burst into one trailing run', t => {
d()
d()
assert.equal(calls, 0)
t.mock.timers.tick(249)
vi.advanceTimersByTime(249)
assert.equal(calls, 0)
t.mock.timers.tick(1)
vi.advanceTimersByTime(1)
assert.equal(calls, 1)
vi.useRealTimers()
})
test('debounce.flush runs now and cancels the pending timer', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
test('debounce.flush runs now and cancels the pending timer', () => {
vi.useFakeTimers()
let calls = 0
const d = debounce(() => {
@@ -147,6 +150,8 @@ test('debounce.flush runs now and cancels the pending timer', t => {
d()
d.flush()
assert.equal(calls, 1)
t.mock.timers.tick(1000)
vi.advanceTimersByTime(1000)
assert.equal(calls, 1)
vi.useRealTimers()
})
+1
View File
@@ -61,6 +61,7 @@ function onScreen(bounds, displays) {
if (!a) {
return false
}
const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x)
const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y)
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { stopBackendChild } from './backend-child'
import { hiddenWindowsChildOptions } from './windows-child-options'
test('hiddenWindowsChildOptions adds windowsHide:true on Windows when unset', () => {
assert.deepEqual(hiddenWindowsChildOptions({}, true), { windowsHide: true })
})
test('hiddenWindowsChildOptions preserves an existing windowsHide:false on Windows', () => {
assert.deepEqual(hiddenWindowsChildOptions({ windowsHide: false }, true), { windowsHide: false })
})
test('hiddenWindowsChildOptions preserves an existing windowsHide:true on Windows', () => {
assert.deepEqual(hiddenWindowsChildOptions({ windowsHide: true }, true), { windowsHide: true })
})
test('hiddenWindowsChildOptions leaves options unchanged off Windows', () => {
assert.deepEqual(hiddenWindowsChildOptions({}, false), {})
assert.deepEqual(hiddenWindowsChildOptions({ stdio: 'ignore' }, false), { stdio: 'ignore' })
})
test('hiddenWindowsChildOptions merges windowsHide alongside other options on Windows', () => {
assert.deepEqual(hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, true), {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true
})
})
test('hiddenWindowsChildOptions defaults isWindows from process.platform when omitted', () => {
const result = hiddenWindowsChildOptions({})
const expectedHide = process.platform === 'win32'
assert.equal(Boolean(result.windowsHide), expectedHide)
})
function makeChild(overrides: Partial<{ pid: number | null; killed: boolean }> = {}) {
const calls: string[] = []
return {
calls,
child: {
kill: (signal: string) => {
calls.push(signal)
},
killed: overrides.killed ?? false,
pid: 'pid' in overrides ? overrides.pid : 1234
}
}
}
test('stopBackendChild tree-kills on Windows when the child has a pid', () => {
const { child, calls } = makeChild({ pid: 4242 })
const treeKillCalls: number[] = []
stopBackendChild(child, {
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
isWindows: true
})
assert.deepEqual(treeKillCalls, [4242])
assert.deepEqual(calls, [], 'SIGTERM must not be sent when the Windows tree-kill path is taken')
})
test('stopBackendChild sends SIGTERM on non-Windows platforms', () => {
const { child, calls } = makeChild({ pid: 4242 })
const treeKillCalls: number[] = []
stopBackendChild(child, {
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
isWindows: false
})
assert.deepEqual(calls, ['SIGTERM'])
assert.deepEqual(treeKillCalls, [], 'tree-kill must not run off Windows')
})
test('stopBackendChild falls back to SIGTERM on Windows when the pid is not an integer', () => {
const { child, calls } = makeChild({ pid: null })
const treeKillCalls: number[] = []
stopBackendChild(child, {
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
isWindows: true
})
assert.deepEqual(calls, ['SIGTERM'])
assert.deepEqual(treeKillCalls, [])
})
test('stopBackendChild is a no-op for an already-killed child', () => {
const { child, calls } = makeChild({ killed: true })
const treeKillCalls: number[] = []
stopBackendChild(child, {
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
isWindows: true
})
assert.deepEqual(calls, [])
assert.deepEqual(treeKillCalls, [])
})
test('stopBackendChild is a no-op for a null/undefined child', () => {
const treeKillCalls: number[] = []
assert.doesNotThrow(() => {
stopBackendChild(null, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true })
stopBackendChild(undefined, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true })
})
assert.deepEqual(treeKillCalls, [])
})
test('stopBackendChild swallows errors thrown by the kill strategy', () => {
const child = {
kill: () => {
throw new Error('ESRCH: no such process')
},
killed: false,
pid: 99
}
assert.doesNotThrow(() => {
stopBackendChild(child, {
forceKillProcessTree: () => {},
isWindows: false
})
})
})
@@ -0,0 +1,37 @@
/**
* windows-child-options.ts
*
* Shared helper for opting Windows child processes (spawn/execFileSync) into
* a hidden console. Windows spawns a visible console window per child by
* default; every desktop-launched helper process (git, curl, taskkill, the
* backend itself, the bootstrap PowerShell runner, ...) needs `windowsHide:
* true` so the user doesn't see consoles flashing on screen.
*
* Extracted into its own dependency-free module (no electron import) so it
* can be unit-tested directly for both platforms without reading source
* text, and so main.ts and bootstrap-runner.ts share exactly one
* implementation instead of each defining their own copy.
*/
import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process'
/**
* Merge `windowsHide: true` into `options` when running on Windows, unless
* the caller already specified a `windowsHide` value (which is preserved
* as-is, including an explicit `false` for cases that intentionally want a
* visible/interactive console). No-op on non-Windows platforms.
*
* @param options - spawn/execFileSync options to (possibly) augment.
* @param isWindows - defaults to the real platform check; injectable for
* tests so both branches can be exercised without mocking process.platform.
*/
export function hiddenWindowsChildOptions(
options: any = {},
isWindows: boolean = process.platform === 'win32'
): ExecFileSyncOptionsWithStringEncoding {
if (!isWindows || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options as any
}
return { ...options, windowsHide: true } as any
}
@@ -1,118 +0,0 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const ELECTRON_DIR = path.dirname(fileURLToPath(import.meta.url))
// TODO FIXME these tests all grep source code for specific things. This is an antipattern.
// Tests should NEVER read src, only assert behavior.
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
}
function requireHiddenChildOptions(source, needle) {
const match = needle instanceof RegExp ? needle.exec(source) : null
const index = needle instanceof RegExp ? (match?.index ?? -1) : source.indexOf(needle)
assert.notEqual(index, -1, `missing call site: ${needle}`)
const snippet = source.slice(index, index + 700)
assert.match(
snippet,
/hiddenWindowsChildOptions\(/,
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
)
}
test('desktop background child processes opt into hidden Windows consoles', () => {
const source = readElectronFile('main.ts')
assert.match(source, /function hiddenWindowsChildOptions\(options: any = \{\}\)/)
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/)
requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/)
requireHiddenChildOptions(source, "execFileSync('taskkill'")
requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/)
requireHiddenChildOptions(source, "spawn('curl'")
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/)
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, backendArgs\)/)
assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/)
assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/)
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/)
})
test('desktop backend launches console python so child consoles are inherited, not pythonw', () => {
const source = readElectronFile('main.ts')
// The flash fix is structural: the backend runs as a console-subsystem
// python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it
// owns ONE windowless console that every descendant spawn inherits. Launching
// it as GUI-subsystem pythonw.exe is what made each child allocate (and flash)
// its own console, so the backend command must never be pythonw.
assert.doesNotMatch(source, /pythonw\.exe'\)/, 'backend must not be launched via pythonw.exe')
assert.doesNotMatch(
source,
/function getNoConsoleVenvPython\b/,
'pythonw-conversion helper should be gone; console python is launched directly'
)
assert.doesNotMatch(
source,
/function applyWindowsNoConsoleSpawnHints\b/,
'pythonw spawn-hint rewriter should be gone'
)
// Console python restores stdout, so the port is announced on the normal
// HERMES_DASHBOARD_READY stdout line — no ready-file side channel is set.
assert.doesNotMatch(source, /readyFile: true/, 'no backend should opt into the pythonw ready-file path')
// Both desktop backend launches must still go through hiddenWindowsChildOptions
// so the single backend console is created windowless.
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
})
test('desktop backend teardown tree-kills Windows backend descendants', () => {
const source = readElectronFile('main.ts')
const helperIndex = source.indexOf('function stopBackendChild(child)')
assert.notEqual(helperIndex, -1, 'missing backend teardown helper')
const helperSnippet = source.slice(helperIndex, helperIndex + 500)
assert.match(helperSnippet, /IS_WINDOWS && Number\.isInteger\(child\.pid\)/)
assert.match(helperSnippet, /forceKillProcessTree\(child\.pid\)/)
assert.match(helperSnippet, /child\.kill\('SIGTERM'\)/)
const resetIndex = source.indexOf('function resetHermesConnection()')
assert.notEqual(resetIndex, -1, 'missing resetHermesConnection')
const resetSnippet = source.slice(resetIndex, resetIndex + 300)
assert.match(resetSnippet, /stopBackendChild\(hermesProcess\)/)
assert.doesNotMatch(resetSnippet, /hermesProcess\.kill\('SIGTERM'\)/)
const quitIndex = source.indexOf("app.on('before-quit'")
assert.notEqual(quitIndex, -1, 'missing before-quit handler')
const quitSnippet = source.slice(quitIndex, quitIndex + 900)
assert.match(quitSnippet, /stopBackendChild\(hermesProcess\)/)
assert.doesNotMatch(quitSnippet, /hermesProcess\.kill\('SIGTERM'\)/)
})
test('intentional or interactive desktop child processes stay documented', () => {
const source = readElectronFile('main.ts')
assert.match(source, /windowsHide: false/)
assert.match(source, /handOffWindowsBootstrapRecovery/)
assert.match(source, /'--repair', '--branch'/)
assert.match(source, /'--update', '--branch'/)
assert.match(source, /nodePty\.spawn\(command, args/)
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
})
test('bootstrap PowerShell runner hides Windows console children', () => {
const source = readElectronFile('bootstrap-runner.ts')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/)
})
@@ -0,0 +1,209 @@
// Unit tests for the pure Windows `hermes` resolution helpers extracted from
// main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and
// unwrapWindowsVenvHermesCommand(). These pin the two Windows resolution bugs
// that caused desktop reinstall loops:
// 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the
// empty extension, or an extensionless Git-Bash `hermes` shim shadows
// the real hermes.cmd/hermes.exe.
// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install
// signal), not just the hermes.exe console-script shim, or healthy
// installs get forced into a destructive --repair.
// 3. resolveVenvHermesCommand() — must probe the venv python via
// canImportHermesCli() before trusting it, or a broken venv gets
// re-selected forever instead of falling through to bootstrap.
import assert from 'node:assert/strict'
import path from 'node:path'
import { test } from 'vitest'
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
test('buildPathExtCandidates: Windows tries PATHEXT extensions before the empty extension', () => {
const extensions = buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', true)
assert.deepEqual(extensions, ['.COM', '.EXE', '.BAT', '.CMD', ''])
assert.equal(extensions[extensions.length - 1], '', 'empty extension must be last, not first')
assert.notEqual(extensions[0], '', 'the buggy empty-extension-first order must not return')
})
test('buildPathExtCandidates: defaults to .COM;.EXE;.BAT;.CMD when PATHEXT is unset on Windows', () => {
assert.deepEqual(buildPathExtCandidates(undefined, true), ['.COM', '.EXE', '.BAT', '.CMD', ''])
})
test('buildPathExtCandidates: respects a custom PATHEXT, still empty-last', () => {
assert.deepEqual(buildPathExtCandidates('.EXE;.PS1', true), ['.EXE', '.PS1', ''])
})
test('buildPathExtCandidates: non-Windows only tries the bare name', () => {
assert.deepEqual(buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', false), [''])
assert.deepEqual(buildPathExtCandidates(undefined, false), [''])
})
test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => {
assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main'])
})
test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => {
assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main'])
})
test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => {
assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2'])
assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2'])
})
function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>[2]> = {}) {
return {
isWindows: true,
isCommandScript: () => false,
fileExists: () => true,
directoryExists: () => false,
canImportHermesCli: () => true,
getVenvPython: (venvRoot: string) => `${venvRoot}/Scripts/python.exe`,
getVenvSitePackagesEntries: () => [],
buildDesktopBackendEnv: () => ({ FAKE_ENV: '1' }),
hermesHome: '/fake/hermes-home',
resolvePath: (...segments: string[]) => segments.join('/').replace(/\/+/g, '/'),
dirname: (p: string) => p.slice(0, p.lastIndexOf('/')) || '/',
basename: (p: string) => p.slice(p.lastIndexOf('/') + 1),
rememberLog: () => {},
...overrides
}
}
test('resolveVenvHermesCommand: returns null off Windows', () => {
const deps = makeDeps({ isWindows: false })
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
})
test('resolveVenvHermesCommand: returns null for a .cmd/.bat script command', () => {
const deps = makeDeps({ isCommandScript: () => true })
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.cmd', [], deps), null)
})
test('resolveVenvHermesCommand: returns null when the basename is not hermes/hermes.exe', () => {
const deps = makeDeps()
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/python.exe', [], deps), null)
})
test('resolveVenvHermesCommand: returns null when the parent dir is not Scripts', () => {
const deps = makeDeps()
assert.equal(resolveVenvHermesCommand('/root/venv/bin/hermes.exe', [], deps), null)
})
test('resolveVenvHermesCommand: returns null when the venv python does not exist on disk', () => {
const deps = makeDeps({ fileExists: () => false })
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
})
test('resolveVenvHermesCommand: probes the venv python before trusting it (returns null on failed probe)', () => {
let probed = false
const deps = makeDeps({
canImportHermesCli: (python: string) => {
probed = true
assert.equal(python, '/root/venv/Scripts/python.exe')
return false
}
})
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve'], deps)
assert.equal(probed, true, 'must probe the venv interpreter; a broken venv must not be re-selected forever')
assert.equal(result, null, 'a failed probe must fall through (return null) so the resolver reaches bootstrap')
})
test('resolveVenvHermesCommand: returns the resolved python backend descriptor when the probe passes', () => {
const deps = makeDeps()
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve', '--port', '0'], deps)
assert.ok(result, 'a passing probe must return a backend descriptor, not null')
assert.equal(result.command, '/root/venv/Scripts/python.exe')
assert.deepEqual(result.args, ['-m', 'hermes_cli.main', 'serve', '--port', '0'])
assert.equal(result.bootstrap, false)
assert.equal(result.kind, 'python')
assert.equal(result.shell, false)
assert.deepEqual(result.env, { FAKE_ENV: '1' })
})
test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Scripts dir name', () => {
const deps = makeDeps()
assert.ok(resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps))
assert.ok(resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps))
})
// ── getVenvSitePackagesEntries ─────────────────────────────────────────────
test('getVenvSitePackagesEntries: returns Lib/site-packages on Windows when it exists', () => {
const expected = path.join('C:\\venv', 'Lib', 'site-packages')
const result = getVenvSitePackagesEntries('C:\\venv', {
isWindows: true,
directoryExists: p => p === expected
})
assert.deepEqual(result, [expected])
})
test('getVenvSitePackagesEntries: returns empty on Windows when site-packages does not exist', () => {
const result = getVenvSitePackagesEntries('C:\\venv', {
isWindows: true,
directoryExists: () => false
})
assert.deepEqual(result, [])
})
test('getVenvSitePackagesEntries: reads pyvenv.cfg version on POSIX and resolves lib/pythonX.Y/site-packages', () => {
const result = getVenvSitePackagesEntries('/venv', {
isWindows: false,
directoryExists: p => p === '/venv/lib/python3.12/site-packages',
readFile: () => 'version_info = 3.12.1\n'
})
assert.deepEqual(result, ['/venv/lib/python3.12/site-packages'])
})
test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg is missing', () => {
const result = getVenvSitePackagesEntries('/venv', {
isWindows: false,
directoryExists: () => true,
readFile: () => undefined
})
assert.deepEqual(result, [])
})
test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg has no version_info', () => {
const result = getVenvSitePackagesEntries('/venv', {
isWindows: false,
directoryExists: () => true,
readFile: () => 'home = /usr/bin\n'
})
assert.deepEqual(result, [])
})
test('getVenvSitePackagesEntries: returns empty on POSIX when version is present but site-packages dir is absent', () => {
const result = getVenvSitePackagesEntries('/venv', {
isWindows: false,
directoryExists: () => false,
readFile: () => 'version_info = 3.11\n'
})
assert.deepEqual(result, [])
})
test('getVenvSitePackagesEntries: returns empty for a falsy venvRoot', () => {
assert.deepEqual(getVenvSitePackagesEntries('', { isWindows: true, directoryExists: () => true }), [])
assert.deepEqual(getVenvSitePackagesEntries(null, { isWindows: true, directoryExists: () => true }), [])
assert.deepEqual(getVenvSitePackagesEntries(undefined, { isWindows: true, directoryExists: () => true }), [])
})
@@ -0,0 +1,287 @@
/**
* windows-hermes-path.ts
*
* Pure, dependency-injected pieces of Windows `hermes` resolution pulled out
* of main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and
* unwrapWindowsVenvHermesCommand(). Each of the three functions here pins one
* of the Windows resolution bugs that caused desktop reinstall loops:
*
* 1. buildPathExtCandidates() — findOnPath() tried the empty extension
* FIRST, so an extensionless Git-Bash `hermes` shim shadowed the real
* hermes.cmd/hermes.exe; the shim then failed the --version probe and
* the desktop fell through to a spurious bootstrap/repair. The fix:
* PATHEXT extensions first, empty extension LAST.
* 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose
* --update vs the destructive --repair by checking ONLY
* venv\Scripts\hermes.exe (the console-script shim, written at the END
* of venv setup and absent in interrupted states), so it escalated to a
* full venv recreate even on healthy installs. The fix: gate on ANY
* real-install signal, not just the shim.
* 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned
* the venv python with NO runtime probe (bypassing the caller's
* --version check too), so a venv broken mid-update (e.g. missing
* python-dotenv) was re-selected forever: Retry / "Repair install"
* resolved the same dead interpreter instead of falling through to the
* bootstrap installer. The fix: probe-before-trust.
*
* Kept in a standalone ts module (no Electron imports, dependencies passed
* as parameters) so it can be unit-tested with `node --test` without
* mocking Electron or the filesystem, same pattern as backend-probes.ts and
* backend-command.ts.
*/
import fs from 'node:fs'
import path from 'node:path'
/**
* Build the ordered list of extensions findOnPath() should try when
* resolving a bare command name off PATH.
*
* On Windows this MUST try PATHEXT extensions (.COM;.EXE;.BAT;.CMD by
* default) BEFORE the bare/empty-extension name: a real command resolves via
* its .exe/.cmd per Windows command-resolution semantics, and an
* extensionless file (e.g. a Git-Bash shell-script shim named `hermes`) must
* not shadow `hermes.cmd`/`hermes.exe`. The empty entry is kept LAST so
* callers that already include the extension (py.exe, pwsh.exe,
* powershell.exe) still resolve.
*
* On non-Windows platforms there is no PATHEXT concept: only the bare name
* is tried.
*
* @param {string | undefined} pathext - process.env.PATHEXT (or undefined).
* @param {boolean} isWindows
* @returns {string[]} extensions to try, in order, always ending in ''.
*/
export function buildPathExtCandidates(pathext: string | undefined, isWindows: boolean): string[] {
if (!isWindows) {
return ['']
}
return [...(pathext || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
}
/**
* Choose the Windows bootstrap-recovery updater invocation: the gentle
* in-place --update when ANY real-install signal is present, the
* destructive --repair (full venv recreate) otherwise.
*
* haveRealInstall must be computed by the caller from ALL real-install
* signals (venv python interpreter, venv hermes shim, bootstrap-complete
* marker) — gating on just the hermes.exe console-script shim alone is the
* regression this function's callers must avoid: that shim is written at
* the END of venv setup and is absent in exactly the interrupted/quarantined
* states this recovery exists to heal.
*
* @param {boolean} haveRealInstall
* @param {string} branch
* @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main'].
*/
export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] {
return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
}
/**
* Resolve the site-packages directory entries for a Python venv.
*
* On Windows, venv layout is `<venvRoot>/Lib/site-packages`.
* On POSIX, it's `<venvRoot>/lib/python<version>/site-packages` where
* `<version>` (e.g. `3.12`) is read from the venv's `pyvenv.cfg`
* `version_info` field.
*
* Returns only directories that actually exist on disk. Returns an empty
* array when `venvRoot` is falsy or no matching site-packages dir is found.
*
* Extracted from main.ts so the platform branching can be tested without
* reading source text. `isWindows` and `directoryExists` are injectable;
* `readFile` defaults to `fs.readFileSync` but can be overridden for tests.
*/
export function getVenvSitePackagesEntries(
venvRoot: string | undefined | null,
opts: {
isWindows?: boolean
directoryExists?: (p: string) => boolean
readFile?: (p: string) => string | undefined
} = {}
): string[] {
const entries: string[] = []
if (!venvRoot) {
return entries
}
const isWindows = opts.isWindows ?? process.platform === 'win32'
const directoryExists = opts.directoryExists ?? ((p: string) => {
try {
return fs.statSync(p).isDirectory()
} catch {
return false
}
})
const readFile = opts.readFile ?? ((p: string) => {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return undefined
}
})
if (isWindows) {
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
if (directoryExists(sitePackages)) {
entries.push(sitePackages)
}
return entries
}
const cfg = readFile(path.join(venvRoot, 'pyvenv.cfg'))
const version = (() => {
if (!cfg) {
return null
}
const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im)
return match ? match[1].trim() : null
})()
if (version) {
const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages')
if (directoryExists(sitePackages)) {
entries.push(sitePackages)
}
}
return entries
}
export interface ResolveVenvHermesCommandDeps {
isWindows: boolean
isCommandScript: (command: string) => boolean
fileExists: (filePath: string) => boolean
directoryExists: (filePath: string) => boolean
canImportHermesCli: (python: string, opts?: { env?: Record<string, string> }) => boolean
getVenvPython: (venvRoot: string) => string
getVenvSitePackagesEntries: (venvRoot: string) => string[]
buildDesktopBackendEnv: (opts: {
hermesHome: string
pythonPathEntries: string[]
venvRoot: string
}) => Record<string, string>
hermesHome: string
resolvePath: (...segments: string[]) => string
dirname: (p: string) => string
basename: (p: string) => string
rememberLog?: (message: string) => void
}
/**
* If `command` is a Windows venv `hermes`/`hermes.exe` console-script shim
* (i.e. `<venvRoot>/Scripts/hermes(.exe)`), resolve it to the underlying
* venv python invoked as `python -m hermes_cli.main <backendArgs>` — but
* ONLY after smoke-testing that interpreter with canImportHermesCli(). A
* venv whose update died mid-`pip install` still has python.exe + hermes.exe
* on disk, but the backend dies on its first import (e.g.
* ModuleNotFoundError: dotenv) before the gateway ever binds. Returning it
* unprobed also bypasses the caller's `--version` probe, so Retry/"Repair
* install" re-resolves the same broken venv forever instead of falling
* through to the bootstrap installer.
*
* Mirrors isActiveRuntimeUsable(): probes with the checkout on PYTHONPATH so
* a healthy source-tree venv passes.
*
* Returns null when `command` is not a venv hermes shim, the underlying
* python doesn't exist, or the import probe fails. Otherwise returns the
* resolved backend descriptor.
*/
export function resolveVenvHermesCommand(
command: string,
backendArgs: string[],
deps: ResolveVenvHermesCommandDeps
): {
label: string
command: string
args: string[]
bootstrap: false
env: Record<string, string>
kind: 'python'
root: string
shell: false
} | null {
const {
isWindows,
isCommandScript,
fileExists,
directoryExists,
canImportHermesCli,
getVenvPython,
getVenvSitePackagesEntries,
buildDesktopBackendEnv,
hermesHome,
resolvePath,
dirname,
basename,
rememberLog
} = deps
if (!isWindows || !command || isCommandScript(command)) {
return null
}
const resolved = resolvePath(String(command))
if (!/^hermes(?:\.exe)?$/i.test(basename(resolved))) {
return null
}
const scriptsDir = dirname(resolved)
if (basename(scriptsDir).toLowerCase() !== 'scripts') {
return null
}
const venvRoot = dirname(scriptsDir)
const python = getVenvPython(venvRoot)
if (!fileExists(python)) {
return null
}
const root = dirname(venvRoot)
if (
!canImportHermesCli(python, {
env: {
PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH]
.filter((entry): entry is string => Boolean(entry))
.join(path.delimiter)
}
})
) {
rememberLog?.(
`Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.`
)
return null
}
return {
label: `existing Hermes Python at ${python}`,
command: python,
args: ['-m', 'hermes_cli.main', ...backendArgs],
bootstrap: false,
env: buildDesktopBackendEnv({
hermesHome,
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
venvRoot
}),
kind: 'python',
root,
shell: false
}
}
@@ -1,85 +0,0 @@
// Regression guards for Windows `hermes` resolution in main.ts.
//
// main.ts has no module.exports, so these follow the repo's source-assertion
// test pattern (see windows-child-process.test.ts). They pin the two Windows
// resolution bugs that caused desktop reinstall loops:
// 1. findOnPath() tried the empty extension FIRST, so an extensionless
// Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the
// shim then failed the --version probe and the desktop fell through to a
// spurious bootstrap/repair.
// 2. handOffWindowsBootstrapRecovery() chose --update vs the destructive
// --repair by checking ONLY venv\Scripts\hermes.exe (the console-script
// shim, written at the END of venv setup and absent in interrupted
// states), so it escalated to a full venv recreate even on healthy
// installs.
// 3. unwrapWindowsVenvHermesCommand() returned the venv python with NO
// runtime probe (bypassing the caller's --version check too), so a venv
// broken mid-update (e.g. missing python-dotenv) was re-selected forever:
// Retry / "Repair install" resolved the same dead interpreter instead of
// falling through to the bootstrap installer.
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function readMain() {
return fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
}
test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => {
const source = readMain()
// Fixed order: PATHEXT first, empty string LAST.
assert.match(
source,
/\(process\.env\.PATHEXT \|\| '\.COM;\.EXE;\.BAT;\.CMD'\)\.split\(';'\)\.filter\(Boolean\), ''\]/,
'extensions array must end with the empty string, not start with it'
)
// The buggy empty-first order must not return.
assert.doesNotMatch(
source,
/\['', \.\.\.\(process\.env\.PATHEXT/,
'empty-extension-first order regressed: an extensionless shim can shadow hermes.cmd/.exe'
)
})
test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => {
const source = readMain()
assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall')
assert.match(source, /fileExists\(venvPython\)/, 'recovery must accept the venv interpreter as a real-install signal')
assert.match(
source,
/\.hermes-bootstrap-complete/,
'recovery must accept the bootstrap-complete marker as a real-install signal'
)
assert.match(source, /updaterArgs = haveRealInstall \? \['--update'/, 'updaterArgs must gate on haveRealInstall')
// The old too-narrow check (only venv\Scripts\hermes.exe) must not return.
assert.doesNotMatch(
source,
/updaterArgs = fileExists\(venvHermes\) \?/,
'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair'
)
})
test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => {
const source = readMain()
const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(')
assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.ts')
// Slice out just the function body (up to the next top-level function decl)
const fnEnd = source.indexOf('\nfunction ', fnStart + 1)
const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd)
assert.match(
body,
/canImportHermesCli\(python/,
'unwrap must probe the venv interpreter; returning it unprobed re-selects a broken venv ' +
'forever (Retry/Repair loop on a mid-update venv missing e.g. python-dotenv)'
)
assert.match(
body,
/return null\s*\n\s*\}\s*\n\s*return \{/,
'a failed probe must fall through (return null) so the resolver reaches the bootstrap rung'
)
})
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { test } from 'vitest'
import { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } from './windows-user-env'
@@ -23,6 +23,7 @@ function parseRegQueryValue(stdout, name) {
if (!stdout || !name) {
return null
}
const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
for (const rawLine of String(stdout).split(/\r?\n/)) {
@@ -70,6 +71,7 @@ function readWindowsUserEnvVar(
if (platform !== 'win32' || !name) {
return null
}
let stdout
try {
@@ -88,6 +90,7 @@ function readWindowsUserEnvVar(
if (raw == null) {
return null
}
const expanded = expandWindowsEnvRefs(raw, env).trim()
return expanded || null
+2 -1
View File
@@ -6,7 +6,8 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import test from 'node:test'
import { test } from 'vitest'
import { isPackagedInstallPath } from './workspace-cwd'
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
decodeClipboardImageBase64,
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import { parseDefaultDistro, resolvePickerDefaultPath, wslPosixToWindowsAccessible } from './wsl-path-bridge'
+61 -26
View File
@@ -5,18 +5,18 @@
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { test } from 'vitest'
import {
applyZoomLevel,
clampZoomLevel,
installZoomReassertOnWindowEvents,
percentToZoomLevel,
ZOOM_REASSERT_WINDOW_EVENTS,
ZOOM_STORAGE_KEY,
zoomLevelToPercent
zoomLevelToPercent,
zoomWiringForWindowKind
} from './zoom'
test('storage key stays stable so persisted zoom survives upgrades', () => {
@@ -66,12 +66,14 @@ test('extreme percentages clamp to the level bounds', () => {
test('installZoomReassertOnWindowEvents wires show and restore', () => {
const handlers = new Map()
const win = {
isDestroyed: () => false,
on(event, listener) {
handlers.set(event, listener)
}
}
let calls = 0
installZoomReassertOnWindowEvents(win, () => {
calls += 1
@@ -86,12 +88,14 @@ test('installZoomReassertOnWindowEvents wires show and restore', () => {
test('installZoomReassertOnWindowEvents skips destroyed windows', () => {
const handlers = new Map()
let destroyed = false
const win = {
isDestroyed: () => destroyed,
on(event, listener) {
handlers.set(event, listener)
}
}
let calls = 0
installZoomReassertOnWindowEvents(win, () => {
calls += 1
@@ -101,25 +105,56 @@ test('installZoomReassertOnWindowEvents skips destroyed windows', () => {
assert.equal(calls, 0)
})
// Source assertion (see windows-child-process.test.ts for the established
// pattern): wireCommonWindowHandlers lives in the electron main entry with heavy
// Electron deps, so we assert the wiring contract against source rather than
// booting a BrowserWindow. Locks in that the pet overlay opts OUT of global UI
// zoom while chat windows keep it — the whole reason this fix is scoped.
test('pet overlay opts out of global UI zoom; chat windows keep it', () => {
const electronDir = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(electronDir, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
// The shared helper gates all zoom wiring behind an opt-out flag.
assert.match(source, /function wireCommonWindowHandlers\(win, \{ zoom = true \}/)
// The pet overlay window is the only caller that disables zoom.
assert.match(source, /wireCommonWindowHandlers\(win, \{ zoom: false \}\)/)
// Zoom restore now flows through the shared helper, so createWindow must not
// reassert it directly (that would double-fire and drift from session windows).
const finishLoad = source.indexOf("mainWindow.webContents.once('did-finish-load'")
assert.notEqual(finishLoad, -1, 'missing mainWindow did-finish-load handler')
const snippet = source.slice(finishLoad, finishLoad + 300)
assert.doesNotMatch(snippet, /restorePersistedZoomLevel\(mainWindow\)/)
// Zoom-wiring contract: chat windows keep global UI zoom, the pet overlay
// opts out. Tested via the extracted config — no source-text regex.
test('chat windows opt into zoom', () => {
assert.deepEqual(zoomWiringForWindowKind('chat'), { zoom: true })
})
test('pet overlay opts out of zoom', () => {
assert.deepEqual(zoomWiringForWindowKind('petOverlay'), { zoom: false })
})
test('unknown window kinds default to chat (zoom enabled)', () => {
assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true })
assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true })
})
// The UI Scale settings control drifts out of sync after a restart when zoom
// is applied to the window but the renderer is never told: its $zoomPercent
// store (see store/zoom.ts) only updates from zoom.get() (once, on load) and
// 'hermes:zoom:changed' events. applyZoomLevel is the single funnel every zoom
// path (user set, restore-on-load, lifecycle re-assert) shares, so applying a
// level always notifies — the regression can't come back by forgetting a send.
function fakeWebContents() {
const calls: Array<[string, ...unknown[]]> = []
return {
calls,
setZoomLevel: (level: number) => calls.push(['setZoomLevel', level]),
send: (channel: string, payload: unknown) => calls.push(['send', channel, payload])
}
}
test('applyZoomLevel applies the level then notifies the renderer', () => {
const wc = fakeWebContents()
const applied = applyZoomLevel(wc, 3)
assert.equal(applied, 3)
assert.deepEqual(wc.calls, [
['setZoomLevel', 3],
['send', 'hermes:zoom:changed', { level: 3, percent: zoomLevelToPercent(3) }]
])
})
test('applyZoomLevel clamps garbage before applying and notifying', () => {
const wc = fakeWebContents()
const applied = applyZoomLevel(wc, 999)
const clamped = clampZoomLevel(999)
assert.equal(applied, clamped)
assert.deepEqual(wc.calls, [
['setZoomLevel', clamped],
['send', 'hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }]
])
})
+34
View File
@@ -32,6 +32,22 @@ export function percentToZoomLevel(percent) {
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
}
/**
* Apply a clamped zoom level to a webContents AND notify the renderer, in that
* order. Every path that changes zoom (user action, restore-on-load, lifecycle
* re-assert) funnels through here so the settings UI Scale control can never
* drift from the actually-applied level — the bug where restore set the level
* but forgot to emit 'hermes:zoom:changed', leaving the control stuck at 100%.
* Returns the clamped level so callers can persist it.
*/
export function applyZoomLevel(webContents, level) {
const clamped = clampZoomLevel(level)
webContents.setZoomLevel(clamped)
webContents.send('hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) })
return clamped
}
// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized
// and restored. Re-apply the persisted level on these lifecycle transitions.
export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore']
@@ -46,7 +62,25 @@ export function installZoomReassertOnWindowEvents(win, reassert) {
if (win.isDestroyed?.()) {
return
}
reassert()
})
}
}
/**
* Zoom-wiring decision per window kind. Chat windows (main + session) keep
* global UI zoom; the pet overlay opts out because it sizes its own OS window
* to the sprite and inheriting zoom would crop it.
*
* Extracted so the "pet opts out, everything else opts in" contract is
* unit-testable without booting a BrowserWindow or reading source.
*/
export const ZOOM_WINDOW_CONFIG = {
chat: { zoom: true },
petOverlay: { zoom: false }
} as const
export function zoomWiringForWindowKind(kind) {
return ZOOM_WINDOW_CONFIG[kind] ?? ZOOM_WINDOW_CONFIG.chat
}
+4 -1
View File
@@ -114,6 +114,9 @@ export default [
}
},
{
ignores: ['*.config.*']
files: ['**/*.test.tsx'],
rules: {
'no-restricted-globals': ['warn', 'document']
}
}
]
+5 -3
View File
@@ -38,14 +38,16 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/wsl-path-bridge.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-process.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-resolution.test.ts electron/oauth-session-request.test.ts",
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",
"fix": "npm run lint:fix && npm run fmt",
"test:ui": "vitest run --environment jsdom",
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174"
"test:ui": "vitest run --project ui",
"test:desktop:platforms": "vitest run --project electron",
"test": "vitest run",
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
"check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build"
},
"dependencies": {
"@assistant-ui/react": "^0.12.28",
@@ -1,5 +1,3 @@
"use strict"
// Build-time guard: refuse to hand a half-built renderer to electron-builder.
//
// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`.
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { test } from 'vitest'
import { checkDistBuilt } from '../scripts/assert-dist-built.mjs'
-1
View File
@@ -1,4 +1,3 @@
'use strict'
/**
* before-pack.mjs — electron-builder beforePack hook.
*
+1 -1
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { test } from 'vitest'
import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs'
+203 -15
View File
@@ -13,12 +13,15 @@ import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { dirname, resolve, join } from 'node:path'
import {
chmodSync,
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync
} from 'node:fs'
import { spawnSync } from 'node:child_process'
import { isMain } from './utils.mjs'
const here = dirname(fileURLToPath(import.meta.url))
@@ -77,9 +80,136 @@ function copyBuildRelease(srcDir, destDir) {
}
}
export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) {
const srcRoot = resolveNodePtyRoot()
const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty')
// ─── binary classification ───────────────────────────────────────────
//
// .node files are shared libraries in the target platform's native binary
// format. By reading the first few bytes (magic) we can determine which
// platform a given .node was compiled for, without shelling out to `file`.
//
// ELF (\x7fELF) → linux
// Mach-O 32-bit BE (feedface) → darwin
// Mach-O 64-bit BE (feedfacf) → darwin
// Mach-O 32-bit LE (cefaedfe — CIGAM) → darwin
// Mach-O 64-bit LE (cffaedfe — CIGAM_64) → darwin
// Fat/Universal BE (cafebabe) → darwin
// Fat/Universal LE (bebafeca — FAT_CIGAM) → darwin
// PE (MZ DOS header) → win32
//
// Mach-O and Fat binaries are stored on disk in the host's native byte
// order. On x64/arm64 Darwin (every Apple Silicon + every Intel Mac that
// ships node-pty prebuilds) that is little-endian, so the on-disk magic is
// the CIGAM byte-swapped form, NOT the big-endian MH_MAGIC form. Checking
// only the BE constants misclassifies every real Darwin prebuild as unknown.
//
// Exported for unit testing.
/**
* Classify a native binary's target platform from its magic bytes.
* Returns `'linux'`, `'darwin'`, `'win32'`, or `null` if unrecognized
* or the file cannot be read.
*/
export function classifyNativeBinary(filePath) {
let buf
try {
buf = readFileSync(filePath, { start: 0, end: 63 }) // first 64 bytes
} catch {
return null
}
if (buf.length < 4) return null
// ELF: \x7f E L F
if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
return 'linux'
}
// Mach-O 32-bit (big-endian / MH_MAGIC): feedface
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xce) {
return 'darwin'
}
// Mach-O 64-bit (big-endian / MH_MAGIC_64): feedfacf
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xcf) {
return 'darwin'
}
// Mach-O 32-bit (little-endian / MH_CIGAM): cefaedfe
if (buf[0] === 0xce && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) {
return 'darwin'
}
// Mach-O 64-bit (little-endian / MH_CIGAM_64): cffaedfe
if (buf[0] === 0xcf && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) {
return 'darwin'
}
// Fat/Universal binary (big-endian / FAT_MAGIC): cafebabe
if (buf[0] === 0xca && buf[1] === 0xfe && buf[2] === 0xba && buf[3] === 0xbe) {
return 'darwin'
}
// Fat/Universal binary (little-endian / FAT_CIGAM): bebafeca
if (buf[0] === 0xbe && buf[1] === 0xba && buf[2] === 0xfe && buf[3] === 0xca) {
return 'darwin'
}
// PE: MZ DOS header
if (buf[0] === 0x4d && buf[1] === 0x5a) {
return 'win32'
}
return null
}
/**
* Scan the staged destination tree for .node files and verify each one's
* binary platform matches the requested target. Throws on any mismatch.
*
* This is the fail-closed safety net: even if a prebuild or build/Release
* somehow slipped through with the wrong platform, this catches it before
* the package ships a broken native binary to users.
*/
function validateStagedBinaries(destRoot, targetPlatform) {
const mismatches = []
function scan(dir, relPrefix) {
if (!existsSync(dir)) return
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
scan(join(dir, entry.name), `${relPrefix}${entry.name}/`)
continue
}
if (!entry.name.endsWith('.node')) continue
const fullPath = join(dir, entry.name)
const classified = classifyNativeBinary(fullPath)
if (classified !== targetPlatform) {
mismatches.push({ file: `${relPrefix}${entry.name}`, classified, expected: targetPlatform })
}
}
}
scan(join(destRoot, 'prebuilds'), 'prebuilds/')
scan(join(destRoot, 'build', 'Release'), 'build/Release/')
if (mismatches.length > 0) {
throw new Error(
`[stage-native-deps] native binary platform mismatch (target=${targetPlatform}):\n` +
mismatches
.map((m) => ` ${m.file}: expected ${m.expected}, got ${m.classified ?? 'unknown'}`)
.join('\n') +
`\nRefusing to stage a binary compiled for the wrong platform.`
)
}
}
/**
* Stage node-pty's native runtime dependencies into `destRoot`.
*
* Exported separately from `stageNodePty` so tests can supply a fake
* node-pty source tree without going through real module resolution.
*
* Strategy (fail-closed):
*
* 1. Copy the matching prebuild (`prebuilds/<platform>-<arch>/`) if present.
* 2. Copy `build/Release/` **only when the target matches the host** —
* build/Release contains a binary compiled for the host's platform/arch,
* so staging it for a different target ships a broken app.
* 3. If no native binary was staged:
* - Same platform as host, different arch → run `electron-rebuild --arch`.
* - Different platform from host → throw (cannot cross-compile native
* modules; build on the target platform or provide a prebuild).
* 4. Validate every staged `.node` file's binary platform matches the target.
*/
export function stageNodePtyInto(srcRoot, destRoot, { platform = process.platform, arch = process.arch } = {}) {
const hostMatch = platform === process.platform && arch === process.arch
rmSync(destRoot, { recursive: true, force: true })
mkdirSync(destRoot, { recursive: true })
@@ -91,11 +221,6 @@ export function stageNodePty({ platform = process.platform, arch = process.arch
// lib/**/*.js — the JS surface node-pty's `main` points into.
copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js'])
// build/Release/* — present when node-pty was compiled locally
// (e.g. no prebuild available for this Electron ABI/platform combo).
// Some installs won't have this at all if prebuild-install succeeded.
copyBuildRelease(join(srcRoot, 'build/Release'), join(destRoot, 'build/Release'))
// prebuilds/<platform>-<arch>/* — the prebuild-install payload for the
// *target* we're packaging, not necessarily the host running this script.
// Explicit extensions only, to skip the ~25MB of Windows .pdb symbols
@@ -115,21 +240,84 @@ export function stageNodePty({ platform = process.platform, arch = process.arch
}
if (entry.name === 'spawn-helper') {
cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name))
chmodSync(join(destPrebuild, entry.name), 0o775)
}
}
} else {
console.warn(
`[stage-native-deps] no prebuild found at prebuilds/${platform}-${arch} for node-pty. ` +
`If build/Release/* above is also empty, this target will fail at runtime. ` +
`Run "npx electron-rebuild -w node-pty" for this target, or check that ` +
`node-pty's published prebuilds cover ${platform}-${arch}.`
)
}
// build/Release/* — present when node-pty was compiled locally
// (e.g. no prebuild available for this Electron ABI/platform combo).
// Only stage this when the target matches the host, because
// build/Release contains a binary compiled for the *host's* platform
// and architecture. Staging a host binary for a different target (e.g.
// a macOS Mach-O .node staged for a linux-arm64 target) ships a broken
// app that crashes the first time a terminal is spawned.
if (hostMatch) {
const buildReleaseDir = join(srcRoot, 'build/Release')
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
}
// Check whether a native binary for this target was staged.
const stagedDirs = [
join(destRoot, 'prebuilds', `${platform}-${arch}`),
join(destRoot, 'build/Release')
]
const hasNativeBinary = stagedDirs.some((dir) => {
if (!existsSync(dir)) return false
return readdirSync(dir, { recursive: true }).some((name) => String(name).endsWith('.node'))
})
if (!hasNativeBinary) {
if (platform !== process.platform) {
throw new Error(
`[stage-native-deps] no prebuilt binary for ${platform}-${arch} and ` +
`cannot cross-compile native modules from ${process.platform}-${process.arch}. ` +
`Build on the target platform or provide a prebuild.`
)
}
// Same platform, possibly different arch — rebuild from source with
// the target architecture so electron-rebuild produces the correct
// binary rather than defaulting to the host's arch.
console.log(
`[stage-native-deps] no native binary for ${platform}-${arch}; ` +
`running electron-rebuild (target arch: ${arch})...`
)
const rebuildArgs = [
'../../node_modules/.bin/electron-rebuild',
'-f',
'-w',
'node-pty',
'--arch',
arch
]
const result = spawnSync(process.execPath, rebuildArgs, {
cwd: projectRoot,
stdio: 'inherit'
})
if (result.status !== 0) {
throw new Error(
`electron-rebuild failed for ${platform}-${arch} (exit ${result.status}). ` +
`Cannot stage node-pty without a native binary.`
)
}
// Re-copy build/Release after electron-rebuild populated it.
const buildReleaseDir = join(srcRoot, 'build/Release')
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
}
// Validate every staged .node binary matches the target platform.
validateStagedBinaries(destRoot, platform)
console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`)
return destRoot
}
export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) {
const srcRoot = resolveNodePtyRoot()
const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty')
return stageNodePtyInto(srcRoot, destRoot, { platform, arch })
}
// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch]
if (isMain(import.meta.url)) {
const [platform, arch] = process.argv.slice(2)
@@ -0,0 +1,285 @@
import assert from 'node:assert/strict'
import fs, { existsSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import {
stageNodePtyInto,
classifyNativeBinary
} from '../scripts/stage-native-deps.mjs'
const { join } = path
// ─── fixtures ──────────────────────────────────────────────────────
//
// Create minimal fake .node files with correct magic bytes so the
// binary classifier and the staging validator exercise real code paths
// without needing actual native modules.
/** Write a fake .node file with the given platform's magic bytes. */
function makeFakeNode(filePath, platform) {
const headers = {
linux: Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]), // ELF
// On x64/arm64 Darwin, Mach-O binaries are stored little-endian on disk
// (MH_CIGAM_64 = cffaedfe). This is the form node-pty's prebuilds ship in.
darwin: Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00, 0x00, 0x00]), // Mach-O 64-bit LE (CIGAM_64)
win32: Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // MZ (PE)
}
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, headers[platform] ?? headers.linux)
}
/** Create a minimal fake node-pty source tree in a temp dir. */
function makeFakeNodePty(srcRoot, { prebuildPlatform, prebuildArch } = {}) {
fs.mkdirSync(srcRoot, { recursive: true })
fs.writeFileSync(join(srcRoot, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'lib/index.js' }))
fs.mkdirSync(join(srcRoot, 'lib'), { recursive: true })
fs.writeFileSync(join(srcRoot, 'lib', 'index.js'), 'module.exports = {};')
if (prebuildPlatform && prebuildArch) {
const prebuildDir = join(srcRoot, 'prebuilds', `${prebuildPlatform}-${prebuildArch}`)
makeFakeNode(join(prebuildDir, 'pty.node'), prebuildPlatform)
}
}
// ─── classifyNativeBinary tests ─────────────────────────────────────
test('classifyNativeBinary detects ELF as linux', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'linux')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Mach-O 64-bit BE as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xcf, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Mach-O 64-bit LE (CIGAM_64) as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Mach-O 32-bit BE as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xce, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Mach-O 32-bit LE (CIGAM) as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xce, 0xfa, 0xed, 0xfe, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Fat/Universal BE (cafebabe) as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Fat/Universal LE (bebafeca / FAT_CIGAM) as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xbe, 0xba, 0xfe, 0xca, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects PE (MZ) as win32', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'win32')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary returns null for unrecognized magic', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), null)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary returns null for a missing file', () => {
assert.equal(classifyNativeBinary('/nonexistent/path/to/thing.node'), null)
})
// ─── cross-target regression tests ──────────────────────────────────
//
// The core bug: stageNodePty receives { platform, arch } from
// electron-builder but unconditionally copies host build/Release, staging
// a host binary for a foreign target. These tests prove the fix:
//
// 1. A host build/Release must NOT be staged for a foreign platform.
// 2. A matching prebuild IS staged for a foreign target.
// 3. A foreign target with no prebuild throws (fail closed).
// 4. A host build/Release IS staged for a matching target.
// 5. Validation rejects a binary whose magic bytes don't match the target.
test('cross-target: host build/Release is NOT staged for a foreign platform', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
// Create a node-pty tree with ONLY a host build/Release (no prebuild).
makeFakeNodePty(srcRoot)
const buildReleaseDir = join(srcRoot, 'build', 'Release')
makeFakeNode(join(buildReleaseDir, 'pty.node'), process.platform)
// Request a foreign platform (different from the host).
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
assert.throws(
() => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }),
/cannot cross-compile/i
)
// build/Release must NOT have been copied to the dest tree.
assert.equal(
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
false,
'host build/Release .node must not be staged for a foreign target'
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('cross-target: matching prebuild IS staged for a foreign target', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
// Host is (say) darwin. Request linux-x64, which has a prebuild.
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
makeFakeNodePty(srcRoot, { prebuildPlatform: foreignPlatform, prebuildArch: 'x64' })
// Also create a host build/Release that should NOT be staged.
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' })
// The foreign prebuild must be staged.
const stagedPrebuild = join(destRoot, 'prebuilds', `${foreignPlatform}-x64`, 'pty.node')
assert.equal(existsSync(stagedPrebuild), true, 'foreign prebuild must be staged')
// The host build/Release must NOT be staged.
assert.equal(
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
false,
'host build/Release must not be staged for a foreign target'
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('cross-target: foreign target with no prebuild throws (fail closed)', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
// Create a tree with a host build/Release but no foreign prebuild.
makeFakeNodePty(srcRoot)
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
assert.throws(
() => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }),
/cannot cross-compile/i
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('host-target: host build/Release IS staged for a matching target', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
makeFakeNodePty(srcRoot)
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
stageNodePtyInto(srcRoot, destRoot, { platform: process.platform, arch: process.arch })
assert.equal(
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
true,
'host build/Release must be staged for a matching target'
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('validation rejects a staged binary with the wrong platform magic', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
// Create a prebuild dir that claims to be linux-x64 but contains
// a darwin (Mach-O) binary. This simulates the original bug where
// a host binary ends up in a foreign target's prebuild slot.
makeFakeNodePty(srcRoot, { prebuildPlatform: 'linux', prebuildArch: 'x64' })
// Overwrite the prebuild .node with the WRONG platform magic.
makeFakeNode(join(srcRoot, 'prebuilds', 'linux-x64', 'pty.node'), 'darwin')
assert.throws(
() => stageNodePtyInto(srcRoot, destRoot, { platform: 'linux', arch: 'x64' }),
/platform mismatch/i
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
+3 -4
View File
@@ -42,7 +42,7 @@ const APP = (() => {
const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked')
return {
appPath: unpacked,
binary: path.join(unpacked, 'hermes'),
binary: path.join(unpacked, 'Hermes'),
resourcesPath: path.join(unpacked, 'resources'),
asarPath: path.join(unpacked, 'resources', 'app.asar'),
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
@@ -108,10 +108,9 @@ function expectedNativeDepPaths() {
function ensurePlatformBuilds() {
if (PLATFORM === 'darwin') return
if (PLATFORM === 'win32') return
if (PLATFORM === 'linux') return
die(
`Desktop bundle validation is only wired for darwin / win32 today; platform=${PLATFORM} ` +
`is not yet supported. The thin-installer story for Linux ships in Phase 2 alongside ` +
`install.sh's stage protocol.`
`Desktop bundle validation is only wired for darwin / win32 / linux; platform=${PLATFORM} is not supported.`
)
}
@@ -1,5 +1,3 @@
"use strict"
/**
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
* .exe should pin to at first-launch bootstrap time. This file ships inside
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { I18nProvider } from '@/i18n/context'
@@ -10,12 +10,17 @@ function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
return { id, kind: 'file', label }
}
function renderWithI18n(ui: React.ReactNode) {
return render(
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
{ui}
</I18nProvider>
)
async function renderWithI18n(ui: React.ReactNode) {
let result: ReturnType<typeof render>
await act(async () => {
result = render(
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
{ui}
</I18nProvider>
)
})
return result!
}
describe('AttachmentList', () => {
@@ -23,23 +28,22 @@ describe('AttachmentList', () => {
cleanup()
})
it('renders valid attachments', () => {
it('renders valid attachments', async () => {
const attachments = [makeAttachment('a', 'doc.pdf'), makeAttachment('b', 'img.png')]
renderWithI18n(<AttachmentList attachments={attachments} />)
await renderWithI18n(<AttachmentList attachments={attachments} />)
expect(screen.getByText('doc.pdf')).toBeDefined()
expect(screen.getByText('img.png')).toBeDefined()
})
it('renders empty list without error', () => {
renderWithI18n(<AttachmentList attachments={[]} />)
it('renders empty list without error', async () => {
const { container } = await renderWithI18n(<AttachmentList attachments={[]} />)
const container =
screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
const attachmentList = container.querySelector('[data-slot="composer-attachments"]')
expect(container).toBeDefined()
expect(attachmentList).toBeDefined()
})
it('does not crash when attachments array contains undefined entries', () => {
it('does not crash when attachments array contains undefined entries', async () => {
// Repro: session switch can leave stale/undefined entries in the
// attachments array, causing a TypeError at attachment.refText.
const attachments = [
@@ -48,21 +52,17 @@ describe('AttachmentList', () => {
makeAttachment('b', 'also-good.png')
]
expect(() => {
renderWithI18n(<AttachmentList attachments={attachments} />)
}).not.toThrow()
await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy()
// Only valid attachments should render
expect(screen.getByText('good.pdf')).toBeDefined()
expect(screen.getByText('also-good.png')).toBeDefined()
})
it('does not crash when attachments array contains null entries', () => {
it('does not crash when attachments array contains null entries', async () => {
const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')]
expect(() => {
renderWithI18n(<AttachmentList attachments={attachments} />)
}).not.toThrow()
await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy()
expect(screen.getByText('valid.txt')).toBeDefined()
})
@@ -54,6 +54,10 @@ function Harness({
}
const submitDraft = () => {
if (disabled) {
return
}
const editor = editorRef.current
if (editor) {
@@ -68,16 +72,6 @@ function Harness({
const text = draftRef.current
const payloadPresent = text.trim().length > 0 || attachments.length > 0
if (disabled) {
// Gateway down: queue the draft instead of dropping it (mirrors the real
// queueCurrentDraft, which no-ops on an empty payload).
if (payloadPresent) {
onQueue(text)
}
return
}
if (busy) {
if (payloadPresent) {
onQueue(text)
@@ -99,10 +93,6 @@ function Harness({
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (disabled) {
if (hasLivePayload) {
submitDraft()
}
return
}
@@ -217,17 +207,16 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
expect(onSubmit).not.toHaveBeenCalled()
})
it('queues a reconnect draft on Enter (not send/drain) so it flushes when the gateway returns', async () => {
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
const onSubmit = vi.fn()
const onDrain = vi.fn()
const onQueue = vi.fn()
const { getByTestId } = render(
<Harness
disabled
onCancel={vi.fn()}
onDrain={onDrain}
onQueue={onQueue}
onQueue={vi.fn()}
onSubmit={onSubmit}
queued={['queued-1']}
/>
@@ -241,32 +230,8 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
fireEvent.keyDown(editor, { key: 'Enter' })
})
// The gateway is down, so the message can't send — but it must NOT be
// silently dropped (the #-bug: "type, hit Enter, nothing happens, no
// error"). It queues, and the gateway-open-gated auto-drain sends it later.
expect(onQueue).toHaveBeenCalledWith('draft while reconnecting')
expect(onSubmit).not.toHaveBeenCalled()
expect(editor.textContent).toBe('draft while reconnecting')
expect(onDrain).not.toHaveBeenCalled()
})
it('treats an empty Enter while reconnecting as a no-op (no phantom queue entry)', async () => {
const onQueue = vi.fn()
const onSubmit = vi.fn()
const onDrain = vi.fn()
const { getByTestId } = render(
<Harness disabled onCancel={vi.fn()} onDrain={onDrain} onQueue={onQueue} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = ''
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onQueue).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(onDrain).not.toHaveBeenCalled()
})
})
@@ -28,7 +28,6 @@ interface UseComposerQueueArgs {
clearDraft: () => void
draftRef: RefObject<string>
focusInput: () => void
gatewayConnected: boolean
loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void
onCancel: ChatBarProps['onCancel']
onSubmit: ChatBarProps['onSubmit']
@@ -53,7 +52,6 @@ export function useComposerQueue({
clearDraft,
draftRef,
focusInput,
gatewayConnected,
loadIntoComposer,
onCancel,
onSubmit,
@@ -260,7 +258,7 @@ export function useComposerQueue({
// a stale-session 404) can't strand the entry permanently nor spin-loop. The
// drain lock serializes sends; a remount/reconnect resets the failure counts.
const autoDrainNext = useCallback(() => {
if (busy || !gatewayConnected || drainingQueueRef.current || !activeQueueSessionKey) {
if (busy || drainingQueueRef.current || !activeQueueSessionKey) {
return
}
@@ -291,7 +289,7 @@ export function useComposerQueue({
}
})
.catch(onFail)
}, [activeQueueSessionKey, busy, gatewayConnected, pickDrainHead, queuedPrompts, runDrain, t])
}, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t])
// Re-key on a runtime session-id change. A stable stored id (queueSessionKey)
// never churns, so a change there is a real session switch and must NOT
@@ -308,15 +306,14 @@ export function useComposerQueue({
migrateQueuedPrompts(prev, activeQueueSessionKey)
}, [activeQueueSessionKey, queueSessionKey])
// Queued turns flow whenever the session is idle AND the gateway is open — on
// the busy→false settle edge, on mount/reconnect, on the socket reopening, and
// after a re-key — so a swallowed edge can't strand them. To cancel queued
// turns, the user deletes them from the panel.
// Queued turns flow whenever the session is idle — on the busy→false settle
// edge, on mount/reconnect, and after a re-key — so a swallowed edge can't
// strand them. To cancel queued turns, the user deletes them from the panel.
useEffect(() => {
if (shouldAutoDrain({ isBusy: busy, isConnected: gatewayConnected, queueLength: queuedPrompts.length })) {
if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) {
autoDrainNext()
}
}, [autoDrainNext, busy, gatewayConnected, queuedPrompts.length])
}, [autoDrainNext, busy, queuedPrompts.length])
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
@@ -108,6 +108,10 @@ export function useComposerSubmit({
)
const submitDraft = () => {
if (disabled) {
return
}
// Source the text from the DOM editor, not React state. The AUI composer
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
// render, so on fast typing or IME composition the final keystroke(s) may
@@ -127,17 +131,6 @@ export function useComposerSubmit({
}
}
// Gateway isn't open (a post-boot reconnect keeps the composer editable by
// design). Don't silently drop the Enter — queue the draft so it shows as
// pending and the bounded auto-drain flushes it the instant the socket
// reopens, instead of the message vanishing with no feedback.
if (disabled) {
queueCurrentDraft()
focusInput()
return
}
const text = draftRef.current
const payloadPresent = text.trim().length > 0 || attachments.length > 0
@@ -128,7 +128,6 @@ export function ChatBar({
const { t } = useI18n()
const gatewayState = useStore($gatewayState)
const gatewayConnected = gatewayState === 'open'
const reconnecting = gatewayState === 'closed' || gatewayState === 'error'
const inputDisabled = disabled && !reconnecting
@@ -179,7 +178,6 @@ export function ChatBar({
clearDraft,
draftRef,
focusInput,
gatewayConnected,
loadIntoComposer,
onCancel,
onSubmit,
@@ -569,14 +567,7 @@ export function ChatBar({
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
// Gateway down (a post-boot reconnect keeps the composer editable): don't
// silently swallow the Enter. Route a real draft into submitDraft, which
// queues it so the auto-drain flushes it the instant the socket reopens.
if (disabled) {
if (hasLivePayload) {
submitDraft()
}
return
}
@@ -19,7 +19,7 @@ describe('PreviewPane console state', () => {
vi.unstubAllGlobals()
})
it('does not watch backend-only remote filesystem previews locally', () => {
it('does not watch backend-only remote filesystem previews locally', async () => {
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
const onPreviewFileChanged = vi.fn(() => vi.fn())
$connection.set({ mode: 'remote' } as never)
@@ -31,38 +31,43 @@ describe('PreviewPane console state', () => {
}
})
render(
<PreviewPane
setTitlebarToolGroup={vi.fn()}
target={{
kind: 'file',
label: 'file.txt',
path: '/remote/file.txt',
previewKind: 'text',
source: '/remote/file.txt',
url: 'file:///remote/file.txt'
}}
/>
)
await act(async () => {
render(
<PreviewPane
setTitlebarToolGroup={vi.fn()}
target={{
kind: 'file',
label: 'file.txt',
path: '/remote/file.txt',
previewKind: 'text',
source: '/remote/file.txt',
url: 'file:///remote/file.txt'
}}
/>
)
})
expect(watchPreviewFile).not.toHaveBeenCalled()
expect(onPreviewFileChanged).not.toHaveBeenCalled()
})
it('does not rebuild the pane titlebar group for streamed console logs', () => {
it('does not rebuild the pane titlebar group for streamed console logs', async () => {
const setTitlebarToolGroup = vi.fn()
const rendered = render(
<PreviewPane
setTitlebarToolGroup={setTitlebarToolGroup}
target={{
kind: 'url',
label: 'Preview',
source: 'http://localhost:5174',
url: 'http://localhost:5174'
}}
/>
)
let rendered!: ReturnType<typeof render>
await act(async () => {
rendered = render(
<PreviewPane
setTitlebarToolGroup={setTitlebarToolGroup}
target={{
kind: 'url',
label: 'Preview',
source: 'http://localhost:5174',
url: 'http://localhost:5174'
}}
/>
)
})
const initialCalls = setTitlebarToolGroup.mock.calls.length
const webview = rendered.container.querySelector('webview')
@@ -0,0 +1,160 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import type { HermesGitBaseBranch } from '@/global'
import { useI18n } from '@/i18n'
import { $repoStatus } from '@/store/coding-status'
import { listBaseBranches } from '@/store/projects'
// Filterable combobox for picking the base branch of a new worktree. Lists
// local + remote-tracking branches, defaults to the default branch
// (origin/HEAD, or local main/master when no remote). The current session's
// branch is sorted to the top so it's one click away. The parent owns the
// selected value via `value` / `onValueChange`.
export function BaseBranchPicker({
disabled,
repoPath,
onValueChange,
value
}: {
disabled?: boolean
repoPath: string
onValueChange: (value: string) => void
value: string
}) {
const { t } = useI18n()
const p = t.sidebar.projects
const repoStatus = useStore($repoStatus)
const [branches, setBranches] = useState<HermesGitBaseBranch[]>([])
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const currentBranch = repoStatus?.detached ? null : (repoStatus?.branch ?? null)
const load = useCallback(async () => {
if (!repoPath) {
return
}
setLoading(true)
try {
const list = await listBaseBranches(repoPath)
setBranches(list)
// Default to the remote default (origin/HEAD). Fall back to the local
// default branch (main/master) when no remote exists. The value is
// always a concrete branch — never undefined.
const defaultBranch = list.find(b => b.isDefault)
if (defaultBranch) {
onValueChange(defaultBranch.name)
} else {
onValueChange(list[0]?.name ?? '')
}
} catch {
setBranches([])
} finally {
setLoading(false)
}
}, [repoPath, onValueChange])
// Load on mount so the default branch fills in before the user opens the
// popover — otherwise the button reads "branch off " with nothing after it.
useEffect(() => {
if (branches.length === 0 && !loading) {
void load()
}
}, [branches.length, loading, load])
// Pin the current session's branch to the top, keep the rest in git's
// most-recently-committed order.
const sorted = useMemo(() => {
if (!currentBranch) {
return branches
}
const idx = branches.findIndex(b => b.name === currentBranch)
if (idx <= 0) {
return branches
}
return [branches[idx], ...branches.slice(0, idx), ...branches.slice(idx + 1)]
}, [branches, currentBranch])
// The i18n function returns { before, after } so the branch name can be
// wrapped in its own styled (underlined) span — works for any word order.
const parts = p.branchOff()
return (
<div className="space-y-1.5">
<Popover
onOpenChange={next => {
if (next && branches.length === 0 && !loading) {
void load()
}
setOpen(next)
}}
open={open}
>
<PopoverTrigger asChild>
<Button
className="group w-full flex justify-start items-center min-w-0 gap-1.5 hover:no-underline hover:text-muted-foreground"
disabled={disabled || loading}
size="inline"
variant="text"
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
<span className="shrink-0">{parts.before}</span>
<span className="shrink-0 text-primary underline-offset-4 decoration-current/20 group-hover:underline">
{loading ? '...' : value}
</span>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.75rem" />
<span className="shrink-0">{parts.after}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="z-[140] min-w-(--radix-popover-trigger-width) p-0">
<Command filter={(searchValue, search) => (searchValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}>
<CommandInput autoFocus placeholder={p.baseBranchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{p.baseBranchNone}</CommandEmpty>
<CommandGroup>
{sorted.map(branch => (
<CommandItem
key={branch.name}
onSelect={() => {
onValueChange(branch.name)
setOpen(false)
}}
value={branch.name}
>
<div className="flex items-center justify-start gap-1.5">
<Codicon
className="shrink-0 text-(--ui-text-tertiary)"
name={branch.isRemote ? 'repo' : 'git-branch'}
size="0.8rem"
/>
{branch.isDefault && (
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">★</span>
)}
<span className="truncate">{branch.name}</span>
{value === branch.name && (
<Codicon className="ml-auto shrink-0 text-(--ui-accent)" name="check" size="0.8rem" />
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}
@@ -30,6 +30,8 @@ import { copyPath, listRepoBranches, revealPath, startWorkInRepo, switchBranchIn
import { SidebarCount, SidebarRowLead } from '../chrome'
import { BaseBranchPicker } from './base-branch-picker'
// Branch/worktree labels routinely share a long prefix (`bb/coding-context-…`),
// so plain end-truncation (`truncate`) hides exactly the suffix that tells two
// lanes apart — both render as "bb/coding-context…". Keep the tail pinned and
@@ -142,6 +144,8 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov
// "New worktree": prompt for a branch name, then git spins up a fresh worktree
// for that branch under the repo (the lightest way) and we open a new session
// inside it. Naming is explicit — no auto-generated `hermes/work-<ts>` trees.
// The base branch defaults to the remote default (origin/HEAD); the user can
// pick any local or remote-tracking branch via a filterable combobox.
export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onStarted: (path: string) => void }) {
const { t } = useI18n()
const s = t.sidebar
@@ -152,6 +156,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
const [convertMode, setConvertMode] = useState(false)
const [branches, setBranches] = useState<HermesGitBranch[]>([])
const [branchesLoading, setBranchesLoading] = useState(false)
const [selectedBase, setSelectedBase] = useState('')
const loadBranches = useCallback(async () => {
if (!repoPath) {
@@ -181,7 +186,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
try {
// Pass the typed value as both the dir slug source and the branch, so the
// branch is exactly what the user named (the dir is slugified git-side).
const result = await startWorkInRepo(repoPath, { branch, name: branch })
const result = await startWorkInRepo(repoPath, { base: selectedBase || undefined, branch, name: branch })
if (result) {
onStarted(result.path)
@@ -238,6 +243,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
onClick={() => {
setConvertMode(false)
setName('')
setSelectedBase('')
setOpen(true)
}}
type="button"
@@ -278,22 +284,30 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
</CommandList>
</Command>
) : (
<SanitizedInput
autoFocus
disabled={pending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
setOpen(false)
}
}}
onValueChange={setName}
placeholder={p.branchPlaceholder}
sanitize={gitRef}
value={name}
/>
<>
<SanitizedInput
autoFocus
disabled={pending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
setOpen(false)
}
}}
onValueChange={setName}
placeholder={p.branchPlaceholder}
sanitize={gitRef}
value={name}
/>
<BaseBranchPicker
disabled={pending}
onValueChange={setSelectedBase}
repoPath={repoPath}
value={selectedBase}
/>
</>
)}
{convertMode ? (
@@ -239,6 +239,7 @@ export function DesktopController() {
const {
activeSessionIdRef,
ensureSessionState,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
@@ -619,6 +620,7 @@ export function DesktopController() {
getRouteToken,
navigate,
requestGateway,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
@@ -313,6 +313,7 @@ export function useGatewayBoot({
applyDesktopBootProgress(payload)
})
void desktop
.getBootProgress()
.then(snapshot => applyDesktopBootProgress(snapshot))
@@ -356,7 +357,8 @@ export function useGatewayBoot({
}
})
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
const sourceProfile = normalizeProfileKey($activeGatewayProfile.get())
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile }))
// Wake signals: power resume (macOS/Windows), network coming back, and the
// window regaining focus/visibility. Each nudges an immediate reconnect.

Some files were not shown because too many files have changed in this diff Show More